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