Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
climate.py
Go to the documentation of this file.
1## @file
2## @brief ESPHome climate platform schema and code generation.
3## @ingroup hioc_codegen
4##
5## Defines the experimental IO-Homecontrol heating/climate integration and wires it to the shared
6## hub. Heaters with a window sensor also get a Window binary sensor and an Open Window Detection
7## switch. The heater is polled (default every five minutes); `status_poll_interval` sets the cadence. Shared device-binding logic lives in platform_common.py; only climate-specific entity
8## construction/registration stays here.
9
10import esphome.codegen as cg
11import esphome.config_validation as cv
12from esphome.components import binary_sensor, climate, switch
13from esphome.const import (
14 CONF_DISABLED_BY_DEFAULT,
15 CONF_ID,
16 CONF_MAX_TEMPERATURE,
17 CONF_MIN_TEMPERATURE,
18 CONF_NAME,
19 DEVICE_CLASS_WINDOW,
20 ENTITY_CATEGORY_CONFIG,
21)
22from esphome.core import ID
23
24from . import home_io_control_ns
25from .hub_validators import DEVICE_TYPE_OPTIONS, heating_profile_has_window
26from .platform_common import (
27 companion_entity_name,
28 companion_id_base,
29 create_companion_sensors,
30 inherit_esphome_device,
31 inject_companion_sensor_ids,
32 platform_schema_extension,
33 wire_device_binding,
34 CONF_DEVICE_TYPE,
35 CONF_HOME_IO_CONTROL_ID,
36 CONF_IO_DEVICE_ID,
37 CONF_STATUS_POLL_INTERVAL,
38 CONF_SUBTYPE,
39)
40
41DEPENDENCIES = ["home_io_control"]
42
43IOHomeClimate = home_io_control_ns.class_("IOHomeClimate", climate.Climate, cg.Component)
44IOHomeWindowBinarySensor = home_io_control_ns.class_(
45 "IOHomeWindowBinarySensor", binary_sensor.BinarySensor, cg.Component
46)
47IOHomeOpenWindowDetectionSwitch = home_io_control_ns.class_(
48 "IOHomeOpenWindowDetectionSwitch", switch.Switch, cg.Component
49)
50
51# Internal config keys for the heater-only companion IDs (injected by the post-validator).
52CONF_WINDOW_SENSOR_ID = "_window_sensor_id"
53CONF_WINDOW_DETECTION_SWITCH_ID = "_window_detection_switch_id"
54
55
57 """True when the declared heater kind has a window sensor and a detection switch.
58
59 The kind is the declared io_device_type/io_subtype (subtype 0 when omitted), the same key the
60 C++ heating profile uses. A heater declared without a type gets no companions: nothing at
61 validation time says what it is.
62 """
63 return CONF_DEVICE_TYPE in config and heating_profile_has_window(
64 config[CONF_DEVICE_TYPE], config.get(CONF_SUBTYPE, 0)
65 )
66
67
69 """Declare the heater companion IDs during validation (ESPHome 2026.x StaticVector sizing)."""
70 if _has_window_companions(config):
71 base = companion_id_base(config, CONF_ID)
72 config[CONF_WINDOW_SENSOR_ID] = ID(
73 f"{base}_window_sensor", is_declaration=True, type=IOHomeWindowBinarySensor
74 )
75 config[CONF_WINDOW_DETECTION_SWITCH_ID] = ID(
76 f"{base}_window_detection_switch",
77 is_declaration=True,
78 type=IOHomeOpenWindowDetectionSwitch,
79 )
80 return inject_companion_sensor_ids(config, CONF_ID)
81
82
83# A heater's state moves over minutes and a poll costs one exchange per register, so the interval floor is
84# well above the cover floor (hub_names.MIN_STATUS_POLL_INTERVAL_MS).
85MIN_CLIMATE_POLL_INTERVAL_MS = 60000
86
87
89 # The key is accepted by the shared platform schema; a climate entity raises its floor to one
90 # minute. Without it the hub polls every five minutes.
91 interval = config.get(CONF_STATUS_POLL_INTERVAL)
92 if interval is not None and interval.total_milliseconds < MIN_CLIMATE_POLL_INTERVAL_MS:
93 raise cv.Invalid(
94 f"status_poll_interval must be at least {MIN_CLIMATE_POLL_INTERVAL_MS}ms for the "
95 "climate platform: a heater is polled with one radio exchange per register",
96 path=[CONF_STATUS_POLL_INTERVAL],
97 )
98 return config
99
100
101# The range a heating temperature interface encodes its temperature over. It defaults to -40..+80 degrees
102# Celsius (proto_heating.h) as an assumption; a device that turns out to use another range is told here.
103# A heat pump uses the fixed range, so the keys do not apply to it. Mirrors
104# HEATING_TEMPERATURE_RANGE_MIN_C / _MAX_C.
105DEFAULT_TEMPERATURE_RANGE_C = (-40.0, 80.0)
106TEMPERATURE_RANGE_BOUNDS_C = (-50.0, 100.0)
107
108
110 # Applies to the two keys only when one is given; the other keeps its default.
111 if CONF_MIN_TEMPERATURE not in config and CONF_MAX_TEMPERATURE not in config:
112 return config
113 if config.get(CONF_DEVICE_TYPE) != DEVICE_TYPE_OPTIONS["heating_temperature_interface"]:
114 raise cv.Invalid(
115 "min_temperature and max_temperature apply to io_device_type: heating_temperature_interface "
116 "only: a heat pump uses a fixed range, and every other device has no temperature range"
117 )
118 low, high = TEMPERATURE_RANGE_BOUNDS_C
119 for key in (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE):
120 if key in config and not low <= config[key] <= high:
121 raise cv.Invalid(
122 f"{key} must be between {low:g} and {high:g} degrees Celsius",
123 path=[key],
124 )
125 minimum = config.get(CONF_MIN_TEMPERATURE, DEFAULT_TEMPERATURE_RANGE_C[0])
126 maximum = config.get(CONF_MAX_TEMPERATURE, DEFAULT_TEMPERATURE_RANGE_C[1])
127 if minimum >= maximum:
128 raise cv.Invalid(
129 f"min_temperature ({minimum:g}) must be below max_temperature ({maximum:g}): they are the "
130 "range the device encodes its temperature over, not the range of the card"
131 )
132 return config
133
134
135CONFIG_SCHEMA = cv.All(
136 climate.climate_schema(IOHomeClimate)
137 .extend(platform_schema_extension())
138 .extend(
139 {
140 cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
141 cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
142 }
143 )
144 .extend(cv.COMPONENT_SCHEMA),
145 _validate_climate_poll_interval,
146 _validate_temperature_range,
147 _inject_companion_ids,
148)
149
150
151async def to_code(config):
152 var = cg.new_Pvariable(config[CONF_ID])
153 await cg.register_component(var, config)
154 await climate.register_climate(var, config)
155
156 parent = await cg.get_variable(config[CONF_HOME_IO_CONTROL_ID])
157 await wire_device_binding(var, parent, config)
158 await create_companion_sensors(config, parent)
159
160 if CONF_MIN_TEMPERATURE in config or CONF_MAX_TEMPERATURE in config:
161 cg.add(
162 var.set_temperature_range(
163 config.get(CONF_MIN_TEMPERATURE, DEFAULT_TEMPERATURE_RANGE_C[0]),
164 config.get(CONF_MAX_TEMPERATURE, DEFAULT_TEMPERATURE_RANGE_C[1]),
165 )
166 )
167
168 if CONF_WINDOW_SENSOR_ID in config:
169 window_config = binary_sensor.binary_sensor_schema(
170 IOHomeWindowBinarySensor, device_class=DEVICE_CLASS_WINDOW
171 ).extend(cv.COMPONENT_SCHEMA)(
172 inherit_esphome_device(
173 {
174 CONF_ID: config[CONF_WINDOW_SENSOR_ID],
175 CONF_NAME: companion_entity_name(config, "Window"),
176 },
177 config,
178 )
179 )
180 window_sensor = await binary_sensor.new_binary_sensor(window_config)
181 await cg.register_component(window_sensor, window_config)
182 cg.add(window_sensor.set_parent(parent))
183 cg.add(window_sensor.set_device_id(config[CONF_IO_DEVICE_ID]))
184
185 if CONF_WINDOW_DETECTION_SWITCH_ID in config:
186 # Restore mode DISABLED: the switch shows what the heater reports, never a restored guess.
187 detection_config = switch.switch_schema(
188 IOHomeOpenWindowDetectionSwitch,
189 default_restore_mode="DISABLED",
190 entity_category=ENTITY_CATEGORY_CONFIG,
191 ).extend(cv.COMPONENT_SCHEMA)(
192 inherit_esphome_device(
193 {
194 CONF_ID: config[CONF_WINDOW_DETECTION_SWITCH_ID],
195 CONF_NAME: companion_entity_name(config, "Open Window Detection"),
196 CONF_DISABLED_BY_DEFAULT: True,
197 },
198 config,
199 )
200 )
201 detection_switch = await switch.new_switch(detection_config)
202 await cg.register_component(detection_switch, detection_config)
203 cg.add(detection_switch.set_parent(parent))
204 cg.add(detection_switch.set_device_id(config[CONF_IO_DEVICE_ID]))
_validate_temperature_range(config)
Definition climate.py:109
_validate_climate_poll_interval(config)
Definition climate.py:88
_has_window_companions(config)
Definition climate.py:56
_inject_companion_ids(config)
Definition climate.py:68