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; a heater with a night light (io_model: heater_with_light) can add a dimmable `night_light:`
8## light. 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
9## construction/registration stays here.
10
11import functools
12
13import esphome.codegen as cg
14import esphome.config_validation as cv
15from esphome.components import binary_sensor, climate, light, switch
16from esphome.const import (
17 CONF_DEVICE_ID,
18 CONF_DISABLED_BY_DEFAULT,
19 CONF_ID,
20 CONF_MAX_TEMPERATURE,
21 CONF_MIN_TEMPERATURE,
22 CONF_NAME,
23 CONF_OUTPUT_ID,
24 DEVICE_CLASS_WINDOW,
25 ENTITY_CATEGORY_CONFIG,
26)
27from esphome.core import ID
28
29from . import home_io_control_ns
30from .hub_validators import (
31 DEVICE_TYPE_OPTIONS,
32 FEATURE_NIGHT_LIGHT,
33 FEATURE_WINDOW_COMPANIONS,
34)
35from .platform_common import (
36 INSTANT_LIGHT_TRANSITION,
37 declared_features,
38 companion_entity_name,
39 companion_id_base,
40 create_companion_sensors,
41 inherit_esphome_device,
42 inject_companion_sensor_ids,
43 platform_schema_extension,
44 validate_device_model,
45 validate_register_poll_interval,
46 warn_if_experimental_kind,
47 wire_device_binding,
48 CONF_DEVICE_TYPE,
49 CONF_HOME_IO_CONTROL_ID,
50 CONF_IO_DEVICE_ID,
51)
52
53DEPENDENCIES = ["home_io_control"]
54
55IOHomeClimate = home_io_control_ns.class_("IOHomeClimate", climate.Climate, cg.Component)
56IOHomeWindowBinarySensor = home_io_control_ns.class_(
57 "IOHomeWindowBinarySensor", binary_sensor.BinarySensor, cg.Component
58)
59IOHomeOpenWindowDetectionSwitch = home_io_control_ns.class_(
60 "IOHomeOpenWindowDetectionSwitch", switch.Switch, cg.Component
61)
62IOHomeHeaterNightLight = home_io_control_ns.class_(
63 "IOHomeHeaterNightLight", light.LightOutput, cg.Component
64)
65
66CONF_NIGHT_LIGHT = "night_light"
67
68# Internal config keys for the heater-only companion IDs (injected by the post-validator).
69CONF_WINDOW_SENSOR_ID = "_window_sensor_id"
70CONF_WINDOW_DETECTION_SWITCH_ID = "_window_detection_switch_id"
71
72
74 """True when the declared heater kind has a window sensor and a detection switch.
75
76 The kind is the declared io_device_type/io_subtype (subtype 0 when omitted) and io_model; the
77 answer is the WINDOW_COMPANIONS bit of its table row, so a towel dryer (a model row without it)
78 gets none. A heater declared without a type gets no companions: nothing at validation time says
79 what it is.
80 """
81 return bool(declared_features(config) & FEATURE_WINDOW_COMPANIONS)
82
83
85 """Accept `night_light:` only on a heater kind that has one (the NIGHT_LIGHT bit of its row).
86
87 Unlike require_feature(), this also rejects the key when no io_device_type is declared: the light's
88 registers would otherwise go to a heater nothing says has them.
89 """
90 if CONF_NIGHT_LIGHT not in config or declared_features(config) & FEATURE_NIGHT_LIGHT:
91 return config
92 raise cv.Invalid(
93 "night_light needs a heater with a night light: set io_device_type: electrical_heater, "
94 "io_subtype: 1 and io_model: heater_with_light",
95 path=[CONF_NIGHT_LIGHT],
96 )
97
98
100 """Declare the heater companion IDs during validation (ESPHome 2026.x StaticVector sizing)."""
101 if _has_window_companions(config):
102 base = companion_id_base(config, CONF_ID)
103 config[CONF_WINDOW_SENSOR_ID] = ID(
104 f"{base}_window_sensor", is_declaration=True, type=IOHomeWindowBinarySensor
105 )
106 config[CONF_WINDOW_DETECTION_SWITCH_ID] = ID(
107 f"{base}_window_detection_switch",
108 is_declaration=True,
109 type=IOHomeOpenWindowDetectionSwitch,
110 )
111 return inject_companion_sensor_ids(config, CONF_ID)
112
113
114# The range a heating temperature interface encodes its temperature over. It defaults to -40..+80 degrees
115# Celsius (proto_heating.h) as an assumption; a device that turns out to use another range is told here.
116# A heat pump uses the fixed range, so the keys do not apply to it. Mirrors
117# HEATING_TEMPERATURE_RANGE_MIN_C / _MAX_C.
118DEFAULT_TEMPERATURE_RANGE_C = (-40.0, 80.0)
119TEMPERATURE_RANGE_BOUNDS_C = (-50.0, 100.0)
120
121
123 # Applies to the two keys only when one is given; the other keeps its default.
124 if CONF_MIN_TEMPERATURE not in config and CONF_MAX_TEMPERATURE not in config:
125 return config
126 if config.get(CONF_DEVICE_TYPE) != DEVICE_TYPE_OPTIONS["heating_temperature_interface"]:
127 raise cv.Invalid(
128 "min_temperature and max_temperature apply to io_device_type: heating_temperature_interface "
129 "only: a heat pump uses a fixed range, and every other device has no temperature range"
130 )
131 low, high = TEMPERATURE_RANGE_BOUNDS_C
132 for key in (CONF_MIN_TEMPERATURE, CONF_MAX_TEMPERATURE):
133 if key in config and not low <= config[key] <= high:
134 raise cv.Invalid(
135 f"{key} must be between {low:g} and {high:g} degrees Celsius",
136 path=[key],
137 )
138 minimum = config.get(CONF_MIN_TEMPERATURE, DEFAULT_TEMPERATURE_RANGE_C[0])
139 maximum = config.get(CONF_MAX_TEMPERATURE, DEFAULT_TEMPERATURE_RANGE_C[1])
140 if minimum >= maximum:
141 raise cv.Invalid(
142 f"min_temperature ({minimum:g}) must be below max_temperature ({maximum:g}): they are the "
143 "range the device encodes its temperature over, not the range of the card"
144 )
145 return config
146
147
148CONFIG_SCHEMA = cv.All(
149 climate.climate_schema(IOHomeClimate)
150 .extend(platform_schema_extension())
151 .extend(
152 {
153 cv.Optional(CONF_MIN_TEMPERATURE): cv.temperature,
154 cv.Optional(CONF_MAX_TEMPERATURE): cv.temperature,
155 # A dimmable light; its IDs are declared by the schema's own GenerateIDs.
156 cv.Optional(CONF_NIGHT_LIGHT): light.light_schema(
157 IOHomeHeaterNightLight, light.LightType.BRIGHTNESS_ONLY
158 )
159 .extend(INSTANT_LIGHT_TRANSITION)
160 .extend(cv.COMPONENT_SCHEMA),
161 }
162 )
163 .extend(cv.COMPONENT_SCHEMA),
164 functools.partial(validate_register_poll_interval, platform="climate"),
165 _validate_temperature_range,
166 validate_device_model,
167 _validate_night_light,
168 warn_if_experimental_kind,
169 _inject_companion_ids,
170)
171
172
173async def to_code(config):
174 var = cg.new_Pvariable(config[CONF_ID])
175 await cg.register_component(var, config)
176 await climate.register_climate(var, config)
177
178 parent = await cg.get_variable(config[CONF_HOME_IO_CONTROL_ID])
179 await wire_device_binding(var, parent, config)
180 await create_companion_sensors(config, parent)
181
182 if CONF_MIN_TEMPERATURE in config or CONF_MAX_TEMPERATURE in config:
183 cg.add(
184 var.set_temperature_range(
185 config.get(CONF_MIN_TEMPERATURE, DEFAULT_TEMPERATURE_RANGE_C[0]),
186 config.get(CONF_MAX_TEMPERATURE, DEFAULT_TEMPERATURE_RANGE_C[1]),
187 )
188 )
189
190 if CONF_WINDOW_SENSOR_ID in config:
191 window_config = binary_sensor.binary_sensor_schema(
192 IOHomeWindowBinarySensor, device_class=DEVICE_CLASS_WINDOW
193 ).extend(cv.COMPONENT_SCHEMA)(
194 inherit_esphome_device(
195 {
196 CONF_ID: config[CONF_WINDOW_SENSOR_ID],
197 CONF_NAME: companion_entity_name(config, "Window"),
198 },
199 config,
200 )
201 )
202 window_sensor = await binary_sensor.new_binary_sensor(window_config)
203 await cg.register_component(window_sensor, window_config)
204 cg.add(window_sensor.set_parent(parent))
205 cg.add(window_sensor.set_device_id(config[CONF_IO_DEVICE_ID]))
206
207 if CONF_WINDOW_DETECTION_SWITCH_ID in config:
208 # Restore mode DISABLED: the switch shows what the heater reports, never a restored guess.
209 detection_config = switch.switch_schema(
210 IOHomeOpenWindowDetectionSwitch,
211 default_restore_mode="DISABLED",
212 entity_category=ENTITY_CATEGORY_CONFIG,
213 ).extend(cv.COMPONENT_SCHEMA)(
214 inherit_esphome_device(
215 {
216 CONF_ID: config[CONF_WINDOW_DETECTION_SWITCH_ID],
217 CONF_NAME: companion_entity_name(config, "Open Window Detection"),
218 CONF_DISABLED_BY_DEFAULT: True,
219 },
220 config,
221 )
222 )
223 detection_switch = await switch.new_switch(detection_config)
224 await cg.register_component(detection_switch, detection_config)
225 cg.add(detection_switch.set_parent(parent))
226 cg.add(detection_switch.set_device_id(config[CONF_IO_DEVICE_ID]))
227
228 if CONF_NIGHT_LIGHT in config:
229 night_light_config = config[CONF_NIGHT_LIGHT]
230 if CONF_DEVICE_ID not in night_light_config:
231 inherit_esphome_device(night_light_config, config)
232 night_light = cg.new_Pvariable(night_light_config[CONF_OUTPUT_ID])
233 await cg.register_component(night_light, night_light_config)
234 await light.register_light(night_light, night_light_config)
235 cg.add(night_light.set_parent(parent))
236 cg.add(night_light.set_device_id(config[CONF_IO_DEVICE_ID]))
_validate_temperature_range(config)
Definition climate.py:122
_validate_night_light(config)
Definition climate.py:84
_has_window_companions(config)
Definition climate.py:73
_inject_companion_ids(config)
Definition climate.py:99