diff --git a/ha-thermostat-calibration.yaml b/ha-thermostat-calibration.yaml new file mode 100644 index 0000000..794a570 --- /dev/null +++ b/ha-thermostat-calibration.yaml @@ -0,0 +1,179 @@ +blueprint: + name: Area Valve Temperature Offset Calibration + author: Andreas Gammelgaard Damsbo + description: | + Automatically calibrate the temperature offset of ALL smart radiator valves (TRVs) + in a specified area using readings from an external room temperature sensor. + + The blueprint discovers all climate entities in the chosen area and updates their + corresponding temperature offset entities based on the difference between each + valve's internal sensor and the actual room temperature. + + ⚙️ Example use case: + - **area**: Living Room + - **external_sensor**: sensor.living_room_temperature + - **rounding_step**: 1.0 + + The blueprint will find all thermostats in "Living Room" and calibrate each one + to match the external sensor reading. Thermostats without offset entities are + automatically skipped. + + ➕ Manual correction: + Use the **manual_correction** slider to bias the calculated offset in cases where + valves tend to stop heating too early or overheat even after calibration. + + - Positive value (e.g., +0.3 °C): Valves assume room is warmer → reduce heating sooner + - Negative value (e.g., −0.3 °C): Valves assume room is cooler → prolong heating + + domain: automation + source_url: + + input: + target_area: + name: Area + description: > + Select the area containing the thermostatic valves you want to calibrate. + All climate entities in this area will be automatically calibrated using + the external temperature sensor. + selector: + area: {} + + external_sensor: + name: External temperature sensor + description: > + Select the temperature sensor that measures the actual room temperature + for this area. This should be a reliable sensor that provides accurate + ambient temperature readings. + Example: sensor.living_room_temperature + selector: + entity: + domain: sensor + device_class: temperature + + min_interval: + name: Minimum time between updates + description: > + The minimum amount of time (in seconds) that must pass before offsets + can be updated again. This prevents valves from being recalibrated too often. + A typical value is 300 seconds (5 minutes). + default: 300 + selector: + number: + min: 30 + max: 3600 + step: 1 + mode: box + unit_of_measurement: s + + rounding_step: + name: Rounding step for offset value + description: > + Define the step to which calculated offsets should be rounded. + Use 0.5 for valves that support half-degree steps, or 1.0 for full degrees. + default: 1.0 + selector: + number: + min: 0.1 + max: 2 + step: 0.1 + mode: box + unit_of_measurement: "°C" + + manual_correction: + name: Manual correction (bias) + description: > + Additional manual bias applied to computed offsets **before rounding**. + Use this if valves still behave undesirably after calibration: + - **Positive value** (e.g., +0.1…+1.0 °C): Makes valves think it's **warmer** → **reduces** heating sooner + - **Negative value** (e.g., −0.1…−1.0 °C): Makes valves think it's **cooler** → **prolongs** heating + Recommended start: 0.0 °C. Adjust in small steps (±0.1 °C) and observe behavior. + default: 0.0 + selector: + number: + min: -1.0 + max: 1.0 + step: 0.1 + mode: slider + unit_of_measurement: "°C" + + offset_entity_suffix: + name: Offset entity suffix pattern + description: > + The text pattern used to identify offset number entities. + By default, looks for entities containing "temperature_offset" or "local_temperature_offset". + Only change this if your devices use a different naming pattern. + default: "temperature_offset" + selector: + text: {} + +mode: queued +max: 10 +max_exceeded: silent + +trigger: + - platform: state + entity_id: !input external_sensor + - platform: state + entity_id: climate.* + attribute: current_temperature + +action: + # Rate limiting check + - condition: template + value_template: > + {% set last = state_attr(this.entity_id, 'last_triggered') %} + {{ last is none or (as_timestamp(now()) - as_timestamp(last)) > min_interval }} + + # Process each climate entity in the area + - repeat: + for_each: > + {{ area_entities(target_area) | select('match', '^climate\.') | list }} + sequence: + - variables: + climate_entity: "{{ repeat.item }}" + climate_device: "{{ device_id(climate_entity) }}" + external_temp: "{{ states(external_sensor) | float(0) }}" + valve_temp: "{{ state_attr(climate_entity, 'current_temperature') | float(0) }}" + + # Skip if climate entity is unavailable or has no valid temperature + - condition: template + value_template: > + {{ states(climate_entity) not in ['unknown', 'unavailable'] and valve_temp > 0 and external_temp > 0 }} + + # Find offset entity for this device + - variables: + offset_entity: > + {% set entities = device_entities(climate_device) if climate_device else [] %} + {% set offset_entities = entities | select('match', '^number\.') | select('search', offset_entity_suffix) | list %} + {{ offset_entities[0] if offset_entities else '' }} + + # Skip if no offset entity found + - condition: template + value_template: "{{ offset_entity != '' }}" + + - condition: template + value_template: "{{ states(offset_entity) not in ['unknown', 'unavailable'] }}" + + # Calculate and set new offset + - variables: + current_offset: "{{ states(offset_entity) | float(0) }}" + raw_offset: "{{ external_temp - (valve_temp - current_offset) + manual_correction }}" + new_offset: "{{ ((raw_offset / rounding_step) | round(0)) * rounding_step }}" + + - service: number.set_value + target: + entity_id: "{{ offset_entity }}" + data: + value: "{{ new_offset }}" + + # Small delay between updates to avoid overwhelming the system + - delay: + milliseconds: 100 + +variables: + target_area: !input target_area + external_sensor: !input external_sensor + min_interval: !input min_interval + rounding_step: !input rounding_step + manual_correction: !input manual_correction + offset_entity_suffix: !input offset_entity_suffix