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