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: https://gdamsbo.dk/forgejo/andreas/ha-thermostat-calibration/raw/branch/main/ha-thermostat-calibration.yaml 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 "_local_temperature_offset". Only change this if your devices use a different naming pattern. default: "_local_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