Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
platform_common.py
Go to the documentation of this file.
1## @file
2## @brief Shared ESPHome codegen for IO-Homecontrol device-bound platforms.
3## @ingroup hioc_codegen
4##
5## cover.py, light.py, switch.py and lock.py all bind an ESPHome entity to a hub
6## device: the same config keys, the same auto-generated companion diagnostic sensors
7## (device name, active issue, RSSI, last contact, exchange failures), and the same
8## to_code() wiring (set_parent / set_device_id / device type / subtype / poll
9## interval / linked remotes). This module is the single home for that shared logic so
10## a change lands in one place instead of four — platform files call one
11## inject_companion_sensor_ids() post-validator and one create_companion_sensors()
12## to_code() helper. Platform-specific pieces — entity construction/registration, the
13## cover's ``invert_position`` option, and the cover's favorite/vent companion
14## buttons — deliberately stay in the platform files.
15
16import esphome.codegen as cg
17import esphome.config_validation as cv
18from esphome.components import sensor, text_sensor
19from esphome.const import (
20 CONF_ACCURACY_DECIMALS,
21 CONF_DEVICE_CLASS,
22 CONF_DISABLED_BY_DEFAULT,
23 CONF_ENTITY_CATEGORY,
24 CONF_FORCE_UPDATE,
25 CONF_ID,
26 CONF_NAME,
27 CONF_STATE_CLASS,
28 CONF_UNIT_OF_MEASUREMENT,
29 ENTITY_CATEGORY_DIAGNOSTIC,
30 STATE_CLASS_MEASUREMENT,
31 STATE_CLASS_TOTAL_INCREASING,
32)
33from esphome.components.sensor import DEVICE_CLASS_SIGNAL_STRENGTH
34from esphome.core import ID
35
36from . import (
37 home_io_control_ns,
38 IOHomeControlComponent,
39 CONF_HOME_IO_CONTROL_ID,
40 CONF_LOW_POWER,
41 device_type_expression,
42 inherit_esphome_device,
43 validate_device_id,
44 validate_device_type,
45 validate_linked_remote_entry,
46 validate_status_poll_interval,
47)
48from .hub_names import CONF_SYSTEM_KEY
49from .hub_validators import validate_system_key
50
51# Shared YAML config keys used by every device-bound platform.
52# Named CONF_IO_DEVICE_ID (not CONF_DEVICE_ID) to stay distinct from ESPHome's own
53# CONF_DEVICE_ID ("device_id", the sub-device UI-grouping key from esphome.const) — same string
54# elsewhere in ESPHome, unrelated protocol-address concept here.
55CONF_IO_DEVICE_ID = "io_device_id"
56CONF_LINKED_REMOTES = "linked_remotes"
57CONF_DEVICE_TYPE = "io_device_type"
58CONF_SUBTYPE = "io_subtype"
59CONF_STATUS_POLL_INTERVAL = "status_poll_interval"
60
61# Internal config key for the companion device-name sensor ID (injected by post-validator).
62CONF_DEVICE_NAME_SENSOR_ID = "_device_name_sensor_id"
63# Internal config key for the companion active-issue sensor ID (injected by post-validator).
64CONF_ACTIVE_ISSUE_SENSOR_ID = "_active_issue_sensor_id"
65# Internal config keys for the companion link-health sensor IDs (injected by post-validator).
66CONF_RSSI_SENSOR_ID = "_rssi_sensor_id"
67CONF_LAST_CONTACT_SENSOR_ID = "_last_contact_sensor_id"
68CONF_EXCHANGE_FAILURES_SENSOR_ID = "_exchange_failures_sensor_id"
69CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID = "_unconfirmed_exchanges_sensor_id"
70# Internal config keys for the companion last-command sensor IDs (injected by post-validator).
71CONF_LAST_COMMANDED_BY_SENSOR_ID = "_last_commanded_by_sensor_id"
72CONF_LAST_COMMAND_SOURCE_SENSOR_ID = "_last_command_source_sensor_id"
73
74IOHomeDeviceNameTextSensor = home_io_control_ns.class_(
75 "IOHomeDeviceNameTextSensor", text_sensor.TextSensor, cg.Component
76)
77IOHomeActiveIssueTextSensor = home_io_control_ns.class_(
78 "IOHomeActiveIssueTextSensor", text_sensor.TextSensor, cg.Component
79)
80IOHomeRssiSensor = home_io_control_ns.class_("IOHomeRssiSensor", sensor.Sensor, cg.Component)
81IOHomeLastContactSensor = home_io_control_ns.class_(
82 "IOHomeLastContactSensor", sensor.Sensor, cg.Component
83)
84IOHomeExchangeFailuresSensor = home_io_control_ns.class_(
85 "IOHomeExchangeFailuresSensor", sensor.Sensor, cg.Component
86)
87IOHomeUnconfirmedExchangesSensor = home_io_control_ns.class_(
88 "IOHomeUnconfirmedExchangesSensor", sensor.Sensor, cg.Component
89)
90IOHomeLastCommandedByTextSensor = home_io_control_ns.class_(
91 "IOHomeLastCommandedByTextSensor", text_sensor.TextSensor, cg.Component
92)
93IOHomeLastCommandSourceTextSensor = home_io_control_ns.class_(
94 "IOHomeLastCommandSourceTextSensor", text_sensor.TextSensor, cg.Component
95)
96
97
98# (config key, ID suffix, codegen class) for every auto-generated companion sensor.
99_COMPANION_SENSOR_IDS = (
100 (CONF_DEVICE_NAME_SENSOR_ID, "device_name_sensor", IOHomeDeviceNameTextSensor),
101 (CONF_ACTIVE_ISSUE_SENSOR_ID, "active_issue_sensor", IOHomeActiveIssueTextSensor),
102 (CONF_RSSI_SENSOR_ID, "rssi_sensor", IOHomeRssiSensor),
103 (CONF_LAST_CONTACT_SENSOR_ID, "last_contact_sensor", IOHomeLastContactSensor),
104 (CONF_EXCHANGE_FAILURES_SENSOR_ID, "exchange_failures_sensor", IOHomeExchangeFailuresSensor),
105 (
106 CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID,
107 "unconfirmed_exchanges_sensor",
108 IOHomeUnconfirmedExchangesSensor,
109 ),
110 (CONF_LAST_COMMANDED_BY_SENSOR_ID, "last_commanded_by_sensor", IOHomeLastCommandedByTextSensor),
111 (CONF_LAST_COMMAND_SOURCE_SENSOR_ID, "last_command_source_sensor", IOHomeLastCommandSourceTextSensor),
112)
113
114
115def companion_entity_name(config, suffix):
116 """Derive a companion entity's name from the parent entity name and a suffix."""
117 base_name = config.get(CONF_NAME, "")
118 if base_name:
119 return f"{base_name} {suffix}"
120 return suffix
121
122
123def companion_id_base(config, parent_id_key):
124 """Return the shared ID prefix for a platform's companion entity IDs.
125
126 ESPHome 2026.x sizes its runtime component vector (StaticVector) from the number of
127 component IDs known at the end of schema validation — before to_code() runs. If
128 companion entities are only created inside to_code(), their IDs are not counted and
129 the StaticVector overflows at runtime, silently dropping later components whose
130 setup() then never executes. Companion IDs must therefore be declared during
131 validation, and they all share the prefix returned here.
132
133 ``parent_id_key`` differs per platform: light reads the entity ID from
134 CONF_OUTPUT_ID, while cover, switch and lock read it from CONF_ID. Falls back to the
135 entity's CONF_ID (the sibling LightState, for light) when parent_id_key's own field wasn't
136 manually set, then to a sanitized form of the entity name. Raises cv.Invalid when neither an
137 explicit id: nor a non-empty name: is available to derive a unique prefix from — reachable via
138 the `name: ""`/`name: None` device-name idiom (see validate_entity_name()) without an id:.
139 """
140 from esphome.helpers import sanitize
141
142 parent_id = config[parent_id_key]
143 if parent_id.id:
144 return parent_id.id
145 # Light's parent_id_key is CONF_OUTPUT_ID (the LightOutput), which has no YAML-settable id:
146 # of its own — a user's `id:` lands on CONF_ID (the sibling LightState) instead. Check that
147 # before giving up, so an explicit id: still anchors the empty-name idiom on a light. This is
148 # a no-op for cover/switch/lock, where parent_id_key already *is* CONF_ID.
149 if parent_id_key != CONF_ID:
150 sibling_id = config.get(CONF_ID)
151 if sibling_id is not None and sibling_id.id:
152 return sibling_id.id
153 # When no explicit id: is given, ESPHome auto-generates it after validation.
154 # At this point .id may still be None, so derive from the entity name instead.
155 if not config[CONF_NAME]:
156 # An empty name is ESPHome's device-name idiom (see esphome/core/entity_helpers.py's
157 # get_base_entity_object_id() — the entity then displays as just the sub-device's name).
158 # Without an explicit id: to fall back on, every such entity on the same platform would
159 # sanitize down to the same empty prefix and collide on companion IDs like
160 # "_favorite_button"/"_device_name_sensor" instead of failing loudly.
161 #
162 # The message names both spellings: validate_entity_name() has already normalized
163 # `name: None`/`name: none` to "" by now, so a user who wrote the literal would otherwise
164 # be told their name is "empty" without that word appearing anywhere in their YAML.
165 raise cv.Invalid(
166 'An entity using the device-name idiom — name: "", or the YAML literal name: None / '
167 "name: none, which normalize to the same empty name — must also declare an explicit "
168 "id:, otherwise its companion entity IDs cannot be derived uniquely."
169 )
170 return sanitize(config[CONF_NAME]).lower()
171
172
173def inject_companion_sensor_ids(config, parent_id_key):
174 """Declare every auto-generated companion sensor ID during schema validation.
175
176 Shared post-validator body for every device-bound platform, covering all entries in
177 _COMPANION_SENSOR_IDS. See companion_id_base() for why the IDs must be declared at
178 validation time rather than in to_code().
179 """
180 base = companion_id_base(config, parent_id_key)
181 for conf_key, id_suffix, sensor_class in _COMPANION_SENSOR_IDS:
182 config[conf_key] = ID(
183 f"{base}_{id_suffix}", is_declaration=True, type=sensor_class
184 )
185 return config
186
187
189 """Validate a device-bound platform's `name:`, keeping it required but honoring ESPHome's
190 device-name idiom (`name: ""` or the YAML literal `name: None`/`none` — both mean "this
191 entity displays as just its sub-device's name", see companion_id_base()'s empty-name path).
192
193 ENTITY_BASE_SCHEMA's own Optional(CONF_NAME) validator (`esphome.config_validation
194 ._validate_entity_name`) never runs for these platforms — platform_schema_extension()
195 overrides that key with a Required one — so its None -> "" conversion, its NAME_MAX_LENGTH
196 check, and its '/' handling all have to be reapplied here. Delegating rather than
197 reimplementing keeps `name: ""` / `name: None` behaving exactly as they do in every other
198 ESPHome component, including upstream's own asymmetry: `name: None` additionally requires
199 `esphome: friendly_name:` to be set (matching Home Assistant's own null-name convention),
200 while `name: ""` does not.
201 """
202 # Private, but it is the only place these rules live; a vendored copy would silently drift.
203 value = cv._validate_entity_name(value)
204 # `_entity_base_validator` normally turns a None result into "" for us, but it only runs for
205 # schemas that did not override CONF_NAME the way platform_schema_extension() does.
206 return "" if value is None else value
207
208
210 """Return the shared schema keys every device-bound platform extends with."""
211 return {
212 cv.Required(CONF_NAME): validate_entity_name,
213 cv.GenerateID(CONF_HOME_IO_CONTROL_ID): cv.use_id(IOHomeControlComponent),
214 cv.Required(CONF_IO_DEVICE_ID): validate_device_id,
215 cv.Optional(CONF_DEVICE_TYPE): validate_device_type,
216 cv.Optional(CONF_SUBTYPE): cv.int_range(min=0, max=63),
217 cv.Optional(CONF_LINKED_REMOTES): cv.ensure_list(validate_linked_remote_entry),
218 cv.Optional(CONF_STATUS_POLL_INTERVAL): validate_status_poll_interval,
219 cv.Optional(CONF_LOW_POWER): cv.boolean,
220 # A key this one device keeps of its own, for a device that does not share the hub's
221 # (ADR 0053). Same validator as the hub's system_key; `!secret` works as for any string.
222 cv.Optional(CONF_SYSTEM_KEY): validate_system_key,
223 }
224
225
226async def wire_device_binding(var, parent, config):
227 """Emit the shared to_code() wiring that binds an entity to its hub device.
228
229 Covers set_parent / set_device_id, the optional device type / subtype / status
230 poll interval / low-power class, and the linked-remotes registration loop —
231 identical across all four device-bound platforms.
232 """
233 cg.add(var.set_parent(parent))
234 cg.add(var.set_device_id(config[CONF_IO_DEVICE_ID]))
235
236 if CONF_DEVICE_TYPE in config:
237 cg.add(var.set_device_type(device_type_expression(config[CONF_DEVICE_TYPE])))
238 if CONF_SUBTYPE in config:
239 cg.add(var.set_subtype(config[CONF_SUBTYPE]))
240 if CONF_STATUS_POLL_INTERVAL in config:
241 cg.add(
242 var.set_status_poll_interval(
243 config[CONF_STATUS_POLL_INTERVAL].total_milliseconds
244 )
245 )
246 if CONF_LOW_POWER in config:
247 cg.add(var.set_low_power(config[CONF_LOW_POWER]))
248 if CONF_SYSTEM_KEY in config:
249 cg.add(var.set_system_key(config[CONF_SYSTEM_KEY]))
250
251 if CONF_LINKED_REMOTES in config:
252 for remote_id in config[CONF_LINKED_REMOTES]:
253 if remote_id.startswith("class:"):
254 # validate_linked_remote_entry() already normalized this to 'class:0x<HH>'.
255 type_value = int(remote_id.split(":", 1)[1], 16)
256 cg.add(
257 parent.add_linked_remote_class(
258 device_type_expression(type_value),
259 config[CONF_IO_DEVICE_ID],
260 )
261 )
262 else:
263 cg.add(parent.add_linked_remote(remote_id, config[CONF_IO_DEVICE_ID]))
264
265
266async def _create_companion_text_sensor(config, parent, sensor_id, name, disabled_by_default):
267 """Shared body for the auto-generated companion `text_sensor:` entities."""
268 companion_config = inherit_esphome_device(
269 {
270 CONF_ID: sensor_id,
271 CONF_NAME: name,
272 CONF_DISABLED_BY_DEFAULT: disabled_by_default,
273 CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
274 },
275 config,
276 )
277 companion = await text_sensor.new_text_sensor(companion_config)
278 await cg.register_component(companion, companion_config)
279 cg.add(companion.set_parent(parent))
280 cg.add(companion.set_device_id(config[CONF_IO_DEVICE_ID]))
281
282
283async def _create_link_health_sensor(config, parent, sensor_id, name, **sensor_kwargs):
284 """Shared body for the auto-generated link-health `sensor:` companions.
285
286 All of them (RSSI, Last Contact, Exchange Failures, Unconfirmed Exchanges) are numeric,
287 diagnostic, and disabled by default (noise control); only the name and sensor-specific schema keys
288 (unit/device_class/state_class/accuracy_decimals) differ between them, so those are the
289 only things each call in create_companion_sensors() supplies.
290 """
291 if CONF_STATE_CLASS in sensor_kwargs:
292 # set_state_class() takes a C++ enum, not a raw string; validate_state_class() is what
293 # normal YAML schema validation would apply to turn the string constant into the
294 # EnumValue codegen expects. We build this dict by hand rather than running it through
295 # sensor_schema(), so it must be applied here.
296 sensor_kwargs[CONF_STATE_CLASS] = sensor.validate_state_class(sensor_kwargs[CONF_STATE_CLASS])
297
298 link_health_config = inherit_esphome_device(
299 {
300 CONF_ID: sensor_id,
301 CONF_NAME: name,
302 CONF_DISABLED_BY_DEFAULT: True,
303 CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
304 CONF_FORCE_UPDATE: False,
305 **sensor_kwargs,
306 },
307 config,
308 )
309 var = await sensor.new_sensor(link_health_config)
310 await cg.register_component(var, link_health_config)
311 cg.add(var.set_parent(parent))
312 cg.add(var.set_device_id(config[CONF_IO_DEVICE_ID]))
313
314
315async def create_companion_sensors(config, parent):
316 """Create and register every auto-generated companion diagnostic sensor.
317
318 Single to_code() entry point for the device-bound platforms (the counterpart of
319 inject_companion_sensor_ids()), so adding a companion touches this module only:
320
321 - Device Name: disabled by default (clutter control).
322 - Active Issue: the one enabled-by-default companion — it is the headline diagnostic
323 value that turns a silently-ignored command into a self-explained one (e.g. a
324 wind/rain lockout), so users should see it without an opt-in step. Empty except while
325 a CMD_ERROR_RESP reason is outstanding; see IOHomeActiveIssueTextSensor.
326 - RSSI / Last Contact / Exchange Failures: numeric link-health diagnostics, disabled by
327 default (noise control). Last Contact publishes seconds since the last frame from the
328 device (an age, not a Home Assistant timestamp) and keeps counting up between frames via
329 its own heartbeat; see IOHomeLastContactSensor.
330 - Last Commanded By / Last Command Source: who/what last commanded the device, disabled by
331 default (noise control). Free — decoded from bytes already present in every status reply,
332 no extra radio traffic; see IOHomeLastCommandedByTextSensor.
333 """
335 config,
336 parent,
337 config[CONF_DEVICE_NAME_SENSOR_ID],
338 companion_entity_name(config, "Device Name"),
339 disabled_by_default=True,
340 )
342 config,
343 parent,
344 config[CONF_ACTIVE_ISSUE_SENSOR_ID],
345 companion_entity_name(config, "Active Issue"),
346 disabled_by_default=False,
347 )
349 config,
350 parent,
351 config[CONF_RSSI_SENSOR_ID],
352 companion_entity_name(config, "RSSI"),
353 **{
354 CONF_UNIT_OF_MEASUREMENT: "dBm",
355 CONF_DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH,
356 CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
357 CONF_ACCURACY_DECIMALS: 0,
358 },
359 )
361 config,
362 parent,
363 config[CONF_LAST_CONTACT_SENSOR_ID],
364 companion_entity_name(config, "Last Contact"),
365 **{
366 CONF_UNIT_OF_MEASUREMENT: "s",
367 CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
368 CONF_ACCURACY_DECIMALS: 0,
369 },
370 )
372 config,
373 parent,
374 config[CONF_EXCHANGE_FAILURES_SENSOR_ID],
375 companion_entity_name(config, "Exchange Failures"),
376 **{
377 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
378 CONF_ACCURACY_DECIMALS: 0,
379 },
380 )
382 config,
383 parent,
384 config[CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID],
385 companion_entity_name(config, "Unconfirmed Exchanges"),
386 **{
387 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
388 CONF_ACCURACY_DECIMALS: 0,
389 },
390 )
392 config,
393 parent,
394 config[CONF_LAST_COMMANDED_BY_SENSOR_ID],
395 companion_entity_name(config, "Last Commanded By"),
396 disabled_by_default=True,
397 )
399 config,
400 parent,
401 config[CONF_LAST_COMMAND_SOURCE_SENSOR_ID],
402 companion_entity_name(config, "Last Command Source"),
403 disabled_by_default=True,
404 )
wire_device_binding(var, parent, config)
_create_link_health_sensor(config, parent, sensor_id, name, **sensor_kwargs)
inject_companion_sensor_ids(config, parent_id_key)
_create_companion_text_sensor(config, parent, sensor_id, name, disabled_by_default)
companion_id_base(config, parent_id_key)