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 logging
17
18import esphome.codegen as cg
19import esphome.config_validation as cv
20from esphome.components import sensor, text_sensor
21from esphome.const import (
22 CONF_ACCURACY_DECIMALS,
23 CONF_DEFAULT_TRANSITION_LENGTH,
24 CONF_DEVICE_CLASS,
25 CONF_DISABLED_BY_DEFAULT,
26 CONF_ENTITY_CATEGORY,
27 CONF_FORCE_UPDATE,
28 CONF_ID,
29 CONF_NAME,
30 CONF_STATE_CLASS,
31 CONF_UNIT_OF_MEASUREMENT,
32 ENTITY_CATEGORY_DIAGNOSTIC,
33 STATE_CLASS_MEASUREMENT,
34 STATE_CLASS_TOTAL_INCREASING,
35)
36from esphome.components.sensor import DEVICE_CLASS_SIGNAL_STRENGTH
37from esphome.core import ID
38
39from . import (
40 home_io_control_ns,
41 IOHomeControlComponent,
42 CONF_HOME_IO_CONTROL_ID,
43 CONF_LOW_POWER,
44 device_type_expression,
45 inherit_esphome_device,
46 validate_device_id,
47 validate_device_type,
48 validate_linked_remote_entry,
49 validate_status_poll_interval,
50)
51from .hub_names import CONF_SYSTEM_KEY
52from .hub_validators import (
53 DEVICE_MODEL_OPTIONS,
54 device_features,
55 device_model_expression,
56 device_models,
57 device_type_name,
58 kind_is_experimental,
59 validate_device_model_name,
60 validate_system_key,
61)
62
63_LOGGER = logging.getLogger(__name__)
64
65# The published page that tells a user how to report a device; the anchor is the one the docs site
66# generates for the "Reporting unsupported devices" heading of the contributing page.
67REPORTING_DEVICES_URL = (
68 "https://laberning.github.io/home_io_control/contributing.html#reporting-unsupported-devices"
69)
70
71# Shared YAML config keys used by every device-bound platform.
72# Named CONF_IO_DEVICE_ID (not CONF_DEVICE_ID) to stay distinct from ESPHome's own
73# CONF_DEVICE_ID ("device_id", the sub-device UI-grouping key from esphome.const) — same string
74# elsewhere in ESPHome, unrelated protocol-address concept here.
75CONF_IO_DEVICE_ID = "io_device_id"
76CONF_LINKED_REMOTES = "linked_remotes"
77CONF_DEVICE_TYPE = "io_device_type"
78CONF_SUBTYPE = "io_subtype"
79# A product model below io_device_type / io_subtype, for products that report the same wire type but
80# need different handling (ADR 0067). Only a few (type, subtype) offer one (hub_validators.device_models()).
81CONF_MODEL = "io_model"
82CONF_STATUS_POLL_INTERVAL = "status_poll_interval"
83
84# Internal config key for the companion device-name sensor ID (injected by post-validator).
85CONF_DEVICE_NAME_SENSOR_ID = "_device_name_sensor_id"
86# Internal config key for the companion active-issue sensor ID (injected by post-validator).
87CONF_ACTIVE_ISSUE_SENSOR_ID = "_active_issue_sensor_id"
88# Internal config keys for the companion link-health sensor IDs (injected by post-validator).
89CONF_RSSI_SENSOR_ID = "_rssi_sensor_id"
90CONF_LAST_CONTACT_SENSOR_ID = "_last_contact_sensor_id"
91CONF_EXCHANGE_FAILURES_SENSOR_ID = "_exchange_failures_sensor_id"
92CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID = "_unconfirmed_exchanges_sensor_id"
93# Internal config keys for the companion last-command sensor IDs (injected by post-validator).
94CONF_LAST_COMMANDED_BY_SENSOR_ID = "_last_commanded_by_sensor_id"
95CONF_LAST_COMMAND_SOURCE_SENSOR_ID = "_last_command_source_sensor_id"
96
97IOHomeDeviceNameTextSensor = home_io_control_ns.class_(
98 "IOHomeDeviceNameTextSensor", text_sensor.TextSensor, cg.Component
99)
100IOHomeActiveIssueTextSensor = home_io_control_ns.class_(
101 "IOHomeActiveIssueTextSensor", text_sensor.TextSensor, cg.Component
102)
103IOHomeRssiSensor = home_io_control_ns.class_("IOHomeRssiSensor", sensor.Sensor, cg.Component)
104IOHomeLastContactSensor = home_io_control_ns.class_(
105 "IOHomeLastContactSensor", sensor.Sensor, cg.Component
106)
107IOHomeExchangeFailuresSensor = home_io_control_ns.class_(
108 "IOHomeExchangeFailuresSensor", sensor.Sensor, cg.Component
109)
110IOHomeUnconfirmedExchangesSensor = home_io_control_ns.class_(
111 "IOHomeUnconfirmedExchangesSensor", sensor.Sensor, cg.Component
112)
113IOHomeLastCommandedByTextSensor = home_io_control_ns.class_(
114 "IOHomeLastCommandedByTextSensor", text_sensor.TextSensor, cg.Component
115)
116IOHomeLastCommandSourceTextSensor = home_io_control_ns.class_(
117 "IOHomeLastCommandSourceTextSensor", text_sensor.TextSensor, cg.Component
118)
119
120
121# (config key, ID suffix, codegen class) for every auto-generated companion sensor.
122_COMPANION_SENSOR_IDS = (
123 (CONF_DEVICE_NAME_SENSOR_ID, "device_name_sensor", IOHomeDeviceNameTextSensor),
124 (CONF_ACTIVE_ISSUE_SENSOR_ID, "active_issue_sensor", IOHomeActiveIssueTextSensor),
125 (CONF_RSSI_SENSOR_ID, "rssi_sensor", IOHomeRssiSensor),
126 (CONF_LAST_CONTACT_SENSOR_ID, "last_contact_sensor", IOHomeLastContactSensor),
127 (CONF_EXCHANGE_FAILURES_SENSOR_ID, "exchange_failures_sensor", IOHomeExchangeFailuresSensor),
128 (
129 CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID,
130 "unconfirmed_exchanges_sensor",
131 IOHomeUnconfirmedExchangesSensor,
132 ),
133 (CONF_LAST_COMMANDED_BY_SENSOR_ID, "last_commanded_by_sensor", IOHomeLastCommandedByTextSensor),
134 (CONF_LAST_COMMAND_SOURCE_SENSOR_ID, "last_command_source_sensor", IOHomeLastCommandSourceTextSensor),
135)
136
137
138def companion_entity_name(config, suffix):
139 """Derive a companion entity's name from the parent entity name and a suffix."""
140 base_name = config.get(CONF_NAME, "")
141 if base_name:
142 return f"{base_name} {suffix}"
143 return suffix
144
145
146def companion_id_base(config, parent_id_key):
147 """Return the shared ID prefix for a platform's companion entity IDs.
148
149 ESPHome 2026.x sizes its runtime component vector (StaticVector) from the number of
150 component IDs known at the end of schema validation — before to_code() runs. If
151 companion entities are only created inside to_code(), their IDs are not counted and
152 the StaticVector overflows at runtime, silently dropping later components whose
153 setup() then never executes. Companion IDs must therefore be declared during
154 validation, and they all share the prefix returned here.
155
156 ``parent_id_key`` differs per platform: light reads the entity ID from
157 CONF_OUTPUT_ID, while cover, switch and lock read it from CONF_ID. Falls back to the
158 entity's CONF_ID (the sibling LightState, for light) when parent_id_key's own field wasn't
159 manually set, then to a sanitized form of the entity name. Raises cv.Invalid when neither an
160 explicit id: nor a non-empty name: is available to derive a unique prefix from — reachable via
161 the `name: ""`/`name: None` device-name idiom (see validate_entity_name()) without an id:.
162 """
163 from esphome.helpers import sanitize
164
165 parent_id = config[parent_id_key]
166 if parent_id.id:
167 return parent_id.id
168 # Light's parent_id_key is CONF_OUTPUT_ID (the LightOutput), which has no YAML-settable id:
169 # of its own — a user's `id:` lands on CONF_ID (the sibling LightState) instead. Check that
170 # before giving up, so an explicit id: still anchors the empty-name idiom on a light. This is
171 # a no-op for cover/switch/lock, where parent_id_key already *is* CONF_ID.
172 if parent_id_key != CONF_ID:
173 sibling_id = config.get(CONF_ID)
174 if sibling_id is not None and sibling_id.id:
175 return sibling_id.id
176 # When no explicit id: is given, ESPHome auto-generates it after validation.
177 # At this point .id may still be None, so derive from the entity name instead.
178 if not config[CONF_NAME]:
179 # An empty name is ESPHome's device-name idiom (see esphome/core/entity_helpers.py's
180 # get_base_entity_object_id() — the entity then displays as just the sub-device's name).
181 # Without an explicit id: to fall back on, every such entity on the same platform would
182 # sanitize down to the same empty prefix and collide on companion IDs like
183 # "_favorite_button"/"_device_name_sensor" instead of failing loudly.
184 #
185 # The message names both spellings: validate_entity_name() has already normalized
186 # `name: None`/`name: none` to "" by now, so a user who wrote the literal would otherwise
187 # be told their name is "empty" without that word appearing anywhere in their YAML.
188 raise cv.Invalid(
189 'An entity using the device-name idiom — name: "", or the YAML literal name: None / '
190 "name: none, which normalize to the same empty name — must also declare an explicit "
191 "id:, otherwise its companion entity IDs cannot be derived uniquely."
192 )
193 return sanitize(config[CONF_NAME]).lower()
194
195
196def inject_companion_sensor_ids(config, parent_id_key):
197 """Declare every auto-generated companion sensor ID during schema validation.
198
199 Shared post-validator body for every device-bound platform, covering all entries in
200 _COMPANION_SENSOR_IDS. See companion_id_base() for why the IDs must be declared at
201 validation time rather than in to_code().
202 """
203 base = companion_id_base(config, parent_id_key)
204 for conf_key, id_suffix, sensor_class in _COMPANION_SENSOR_IDS:
205 config[conf_key] = ID(
206 f"{base}_{id_suffix}", is_declaration=True, type=sensor_class
207 )
208 return config
209
210
212 """Validate a device-bound platform's `name:`, keeping it required but honoring ESPHome's
213 device-name idiom (`name: ""` or the YAML literal `name: None`/`none` — both mean "this
214 entity displays as just its sub-device's name", see companion_id_base()'s empty-name path).
215
216 ENTITY_BASE_SCHEMA's own Optional(CONF_NAME) validator (`esphome.config_validation
217 ._validate_entity_name`) never runs for these platforms — platform_schema_extension()
218 overrides that key with a Required one — so its None -> "" conversion, its NAME_MAX_LENGTH
219 check, and its '/' handling all have to be reapplied here. Delegating rather than
220 reimplementing keeps `name: ""` / `name: None` behaving exactly as they do in every other
221 ESPHome component, including upstream's own asymmetry: `name: None` additionally requires
222 `esphome: friendly_name:` to be set (matching Home Assistant's own null-name convention),
223 while `name: ""` does not.
224 """
225 # Private, but it is the only place these rules live; a vendored copy would silently drift.
226 value = cv._validate_entity_name(value)
227 # `_entity_base_validator` normally turns a None result into "" for us, but it only runs for
228 # schemas that did not override CONF_NAME the way platform_schema_extension() does.
229 return "" if value is None else value
230
231
233 """Return the shared schema keys every device-bound platform extends with."""
234 return {
235 cv.Required(CONF_NAME): validate_entity_name,
236 cv.GenerateID(CONF_HOME_IO_CONTROL_ID): cv.use_id(IOHomeControlComponent),
237 cv.Required(CONF_IO_DEVICE_ID): validate_device_id,
238 cv.Optional(CONF_DEVICE_TYPE): validate_device_type,
239 cv.Optional(CONF_SUBTYPE): cv.int_range(min=0, max=63),
240 cv.Optional(CONF_MODEL): validate_device_model_name,
241 cv.Optional(CONF_LINKED_REMOTES): cv.ensure_list(validate_linked_remote_entry),
242 cv.Optional(CONF_STATUS_POLL_INTERVAL): validate_status_poll_interval,
243 cv.Optional(CONF_LOW_POWER): cv.boolean,
244 # A key this one device keeps of its own, for a device that does not share the hub's
245 # (ADR 0053). Same validator as the hub's system_key; `!secret` works as for any string.
246 # cv.sensitive, as on the hub and the 1W controllers, so `esphome config` and the
247 # dashboard mask the key instead of printing it.
248 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
249 }
250
251
252def config_model(config):
253 """The DEVICE_MODEL_OPTIONS value of the entry's io_model:, 0 when it declares none."""
254 return DEVICE_MODEL_OPTIONS.get(config.get(CONF_MODEL), 0)
255
256
257def _kind_label(device_type, subtype, model=None):
258 """The declared kind as a user wrote it: the io_device_type name (or hex value) and, when set,
259 io_subtype and io_model."""
260 name = device_type_name(device_type) or f"0x{device_type:02X}"
261 details = []
262 if subtype != 0:
263 details.append(f"io_subtype {subtype}")
264 if model is not None:
265 details.append(f"io_model {model}")
266 return f"{name} ({', '.join(details)})" if details else name
267
268
270 """Accept io_model: only where the declared io_device_type / io_subtype offers that model.
271
272 A post-validator for every device-bound platform. A model is a row below a (type, subtype)
273 (ADR 0067), so it means nothing without both keys; the error names the models the declared kind
274 offers, or says it offers none.
275 """
276 if CONF_MODEL not in config:
277 return config
278 model = config[CONF_MODEL]
279 if CONF_DEVICE_TYPE not in config or CONF_SUBTYPE not in config:
280 raise cv.Invalid(
281 f"io_model: {model} needs io_device_type and io_subtype: a model is a product below a "
282 "device type and subtype",
283 path=[CONF_MODEL],
284 )
285 device_type = config[CONF_DEVICE_TYPE]
286 subtype = config[CONF_SUBTYPE]
287 offered = device_models(device_type, subtype)
288 if model not in offered:
289 choices = f"it offers: {', '.join(offered)}" if offered else "it has no models"
290 raise cv.Invalid(
291 f"io_model: {model} is not a model of {_kind_label(device_type, subtype)}; {choices}",
292 path=[CONF_MODEL],
293 )
294 return config
295
296
298 """Warn, without rejecting, that the declared kind is not yet confirmed on a real device.
299
300 A post-validator for every device-bound platform. The config is legal and still transmits, so
301 this logs one warning (the pattern of the other warn-only validators) that names the kind and
302 points at the page for reporting a result. A config that declares no kind passes silently.
303 """
304 if CONF_DEVICE_TYPE in config:
305 device_type = config[CONF_DEVICE_TYPE]
306 subtype = config.get(CONF_SUBTYPE, 0)
307 if kind_is_experimental(device_type, subtype, config_model(config)):
308 _LOGGER.warning(
309 "home_io_control: %s is not yet confirmed on a real device, so it may not behave as "
310 "described. If you try it, please report the result: %s",
311 _kind_label(device_type, subtype, config.get(CONF_MODEL)),
312 REPORTING_DEVICES_URL,
313 )
314 return config
315
316
317def deprecated_key(key, replacement):
318 """Build a post-validator that warns, without rejecting, about a key that is being retired.
319
320 The first stage of retiring a key: the config stays legal and behaves as before, and one
321 warning names the entity, says the key will become a config error, and gives the replacement.
322 The second stage swaps this validator for one that raises cv.Invalid with the same replacement.
323
324 @param key The YAML key being retired.
325 @param replacement Called with the validated config when the key is present; returns the
326 sentence(s) saying what to write instead and what changes in Home Assistant.
327 """
328
329 def validator(config):
330 if key in config:
331 _LOGGER.warning(
332 "home_io_control: '%s' uses '%s:', which is deprecated and will become a config "
333 "error. %s",
334 config.get(CONF_NAME) or config[CONF_ID].id or "this entity",
335 key,
336 replacement(config),
337 )
338 return config
339
340 return validator
341
342
343# ESPHome's light_schema() defaults to a 1 s transition, which fades a brightness change client-side by
344# calling write_state() repeatedly over that window. Here each write_state() is a radio exchange (about
345# 300 ms), so a fade becomes a stream of superseding commands, and each device reply can restart it: on real
346# hardware (the Izymo dimmer) it looped indefinitely. Every brightness light of this component extends its
347# schema with this, so changes apply at once unless the YAML asks for a fade.
348INSTANT_LIGHT_TRANSITION = {
349 cv.Optional(CONF_DEFAULT_TRANSITION_LENGTH, default="0s"): cv.positive_time_period_milliseconds
350}
351
352
354 """The capability word of the kind an entry declares (io_device_type, io_subtype, io_model).
355
356 0 when the entry declares no io_device_type: nothing at validation time says what the device is,
357 so it has no feature a validator may rely on.
358 """
359 if CONF_DEVICE_TYPE not in config:
360 return 0
361 return device_features(config[CONF_DEVICE_TYPE], config.get(CONF_SUBTYPE, 0), config_model(config))
362
363
364# A heater's or a water heater's state moves over minutes and its poll costs one radio exchange per register,
365# so these platforms raise the status_poll_interval floor well above the cover floor
366# (hub_names.MIN_STATUS_POLL_INTERVAL_MS). Without the key the hub polls them every five minutes.
367MIN_REGISTER_POLL_INTERVAL_MS = 60000
368
369
371 """Raise the status_poll_interval floor to one minute on a platform whose poll reads registers.
372
373 A post-validator for climate and water_heater, bound to the platform name with functools.partial so the
374 message says which one. The key itself is accepted by the shared platform schema.
375 """
376 interval = config.get(CONF_STATUS_POLL_INTERVAL)
377 if interval is not None and interval.total_milliseconds < MIN_REGISTER_POLL_INTERVAL_MS:
378 raise cv.Invalid(
379 f"status_poll_interval must be at least {MIN_REGISTER_POLL_INTERVAL_MS}ms for the "
380 f"{platform} platform: its poll is one radio exchange per register",
381 path=[CONF_STATUS_POLL_INTERVAL],
382 )
383 return config
384
385
386def require_feature(config, key, active, *, required=0, forbidden=0, reason):
387 """Reject a YAML key the declared device kind cannot use.
388
389 One gate for every feature-dependent key. It only fires when the config declares
390 ``io_device_type`` (without a kind nothing says what the device can do), ``active`` is true
391 (the caller decides, because keys differ: ``silent:`` is active by presence, ``dimmable:``
392 only when true), and the kind's capability word lacks a ``required`` bit or has a
393 ``forbidden`` one. ``reason`` says what to do about it.
394 """
395 if not active or CONF_DEVICE_TYPE not in config:
396 return
397 features = declared_features(config)
398 if (features & required) == required and (features & forbidden) == 0:
399 return
400 label = _kind_label(config[CONF_DEVICE_TYPE], config.get(CONF_SUBTYPE, 0), config.get(CONF_MODEL))
401 raise cv.Invalid(f"{key} is not available on {label}: {reason}")
402
403
404async def wire_device_binding(var, parent, config):
405 """Emit the shared to_code() wiring that binds an entity to its hub device.
406
407 Covers set_parent / set_device_id, the optional device type / subtype / model / status
408 poll interval / low-power class, and the linked-remotes registration loop —
409 identical across all four device-bound platforms.
410 """
411 cg.add(var.set_parent(parent))
412 cg.add(var.set_device_id(config[CONF_IO_DEVICE_ID]))
413
414 if CONF_DEVICE_TYPE in config:
415 cg.add(var.set_device_type(device_type_expression(config[CONF_DEVICE_TYPE])))
416 if CONF_SUBTYPE in config:
417 cg.add(var.set_subtype(config[CONF_SUBTYPE]))
418 if CONF_MODEL in config:
419 cg.add(var.set_device_model(device_model_expression(config_model(config))))
420 if CONF_STATUS_POLL_INTERVAL in config:
421 cg.add(
422 var.set_status_poll_interval(
423 config[CONF_STATUS_POLL_INTERVAL].total_milliseconds
424 )
425 )
426 if CONF_LOW_POWER in config:
427 cg.add(var.set_low_power(config[CONF_LOW_POWER]))
428 if CONF_SYSTEM_KEY in config:
429 cg.add(var.set_system_key(config[CONF_SYSTEM_KEY]))
430
431 if CONF_LINKED_REMOTES in config:
432 for remote_id in config[CONF_LINKED_REMOTES]:
433 if remote_id.startswith("class:"):
434 # validate_linked_remote_entry() already normalized this to 'class:0x<HH>'.
435 type_value = int(remote_id.split(":", 1)[1], 16)
436 cg.add(
437 parent.add_linked_remote_class(
438 device_type_expression(type_value),
439 config[CONF_IO_DEVICE_ID],
440 )
441 )
442 else:
443 cg.add(parent.add_linked_remote(remote_id, config[CONF_IO_DEVICE_ID]))
444
445
446async def _create_companion_text_sensor(config, parent, sensor_id, name, disabled_by_default):
447 """Shared body for the auto-generated companion `text_sensor:` entities."""
448 companion_config = inherit_esphome_device(
449 {
450 CONF_ID: sensor_id,
451 CONF_NAME: name,
452 CONF_DISABLED_BY_DEFAULT: disabled_by_default,
453 CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
454 },
455 config,
456 )
457 companion = await text_sensor.new_text_sensor(companion_config)
458 await cg.register_component(companion, companion_config)
459 cg.add(companion.set_parent(parent))
460 cg.add(companion.set_device_id(config[CONF_IO_DEVICE_ID]))
461
462
463async def _create_link_health_sensor(config, parent, sensor_id, name, **sensor_kwargs):
464 """Shared body for the auto-generated link-health `sensor:` companions.
465
466 All of them (RSSI, Last Contact, Exchange Failures, Unconfirmed Exchanges) are numeric,
467 diagnostic, and disabled by default (noise control); only the name and sensor-specific schema keys
468 (unit/device_class/state_class/accuracy_decimals) differ between them, so those are the
469 only things each call in create_companion_sensors() supplies.
470 """
471 if CONF_STATE_CLASS in sensor_kwargs:
472 # set_state_class() takes a C++ enum, not a raw string; validate_state_class() is what
473 # normal YAML schema validation would apply to turn the string constant into the
474 # EnumValue codegen expects. We build this dict by hand rather than running it through
475 # sensor_schema(), so it must be applied here.
476 sensor_kwargs[CONF_STATE_CLASS] = sensor.validate_state_class(sensor_kwargs[CONF_STATE_CLASS])
477
478 link_health_config = inherit_esphome_device(
479 {
480 CONF_ID: sensor_id,
481 CONF_NAME: name,
482 CONF_DISABLED_BY_DEFAULT: True,
483 CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
484 CONF_FORCE_UPDATE: False,
485 **sensor_kwargs,
486 },
487 config,
488 )
489 var = await sensor.new_sensor(link_health_config)
490 await cg.register_component(var, link_health_config)
491 cg.add(var.set_parent(parent))
492 cg.add(var.set_device_id(config[CONF_IO_DEVICE_ID]))
493
494
495async def create_companion_sensors(config, parent):
496 """Create and register every auto-generated companion diagnostic sensor.
497
498 Single to_code() entry point for the device-bound platforms (the counterpart of
499 inject_companion_sensor_ids()), so adding a companion touches this module only:
500
501 - Device Name: disabled by default (clutter control).
502 - Active Issue: the one enabled-by-default companion — it is the headline diagnostic
503 value that turns a silently-ignored command into a self-explained one (e.g. a
504 wind/rain lockout), so users should see it without an opt-in step. Empty except while
505 an issue is outstanding (a refusal, a derived rain limitation, or a radio that stopped
506 responding); see IOHomeActiveIssueTextSensor.
507 - RSSI / Last Contact / Exchange Failures: numeric link-health diagnostics, disabled by
508 default (noise control). Last Contact publishes seconds since the last frame from the
509 device (an age, not a Home Assistant timestamp) and keeps counting up between frames via
510 its own heartbeat; see IOHomeLastContactSensor.
511 - Last Commanded By / Last Command Source: who/what last commanded the device, disabled by
512 default (noise control). Free — decoded from bytes already present in every status reply,
513 no extra radio traffic; see IOHomeLastCommandedByTextSensor.
514 """
516 config,
517 parent,
518 config[CONF_DEVICE_NAME_SENSOR_ID],
519 companion_entity_name(config, "Device Name"),
520 disabled_by_default=True,
521 )
523 config,
524 parent,
525 config[CONF_ACTIVE_ISSUE_SENSOR_ID],
526 companion_entity_name(config, "Active Issue"),
527 disabled_by_default=False,
528 )
530 config,
531 parent,
532 config[CONF_RSSI_SENSOR_ID],
533 companion_entity_name(config, "RSSI"),
534 **{
535 CONF_UNIT_OF_MEASUREMENT: "dBm",
536 CONF_DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH,
537 CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
538 CONF_ACCURACY_DECIMALS: 0,
539 },
540 )
542 config,
543 parent,
544 config[CONF_LAST_CONTACT_SENSOR_ID],
545 companion_entity_name(config, "Last Contact"),
546 **{
547 CONF_UNIT_OF_MEASUREMENT: "s",
548 CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
549 CONF_ACCURACY_DECIMALS: 0,
550 },
551 )
553 config,
554 parent,
555 config[CONF_EXCHANGE_FAILURES_SENSOR_ID],
556 companion_entity_name(config, "Exchange Failures"),
557 **{
558 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
559 CONF_ACCURACY_DECIMALS: 0,
560 },
561 )
563 config,
564 parent,
565 config[CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID],
566 companion_entity_name(config, "Unconfirmed Exchanges"),
567 **{
568 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
569 CONF_ACCURACY_DECIMALS: 0,
570 },
571 )
573 config,
574 parent,
575 config[CONF_LAST_COMMANDED_BY_SENSOR_ID],
576 companion_entity_name(config, "Last Commanded By"),
577 disabled_by_default=True,
578 )
580 config,
581 parent,
582 config[CONF_LAST_COMMAND_SOURCE_SENSOR_ID],
583 companion_entity_name(config, "Last Command Source"),
584 disabled_by_default=True,
585 )
_kind_label(device_type, subtype, model=None)
wire_device_binding(var, parent, config)
_create_link_health_sensor(config, parent, sensor_id, name, **sensor_kwargs)
validate_register_poll_interval(config, platform)
inject_companion_sensor_ids(config, parent_id_key)
require_feature(config, key, active, *, required=0, forbidden=0, reason)
_create_companion_text_sensor(config, parent, sensor_id, name, disabled_by_default)
companion_id_base(config, parent_id_key)