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,
25 CONF_DISABLED_BY_DEFAULT,
31 CONF_UNIT_OF_MEASUREMENT,
32 ENTITY_CATEGORY_DIAGNOSTIC,
33 STATE_CLASS_MEASUREMENT,
34 STATE_CLASS_TOTAL_INCREASING,
36from esphome.components.sensor
import DEVICE_CLASS_SIGNAL_STRENGTH
37from esphome.core
import ID
41 IOHomeControlComponent,
42 CONF_HOME_IO_CONTROL_ID,
44 device_type_expression,
45 inherit_esphome_device,
48 validate_linked_remote_entry,
49 validate_status_poll_interval,
51from .hub_names
import CONF_SYSTEM_KEY
52from .hub_validators
import (
55 device_model_expression,
59 validate_device_model_name,
63_LOGGER = logging.getLogger(__name__)
67REPORTING_DEVICES_URL = (
68 "https://laberning.github.io/home_io_control/contributing.html#reporting-unsupported-devices"
75CONF_IO_DEVICE_ID =
"io_device_id"
76CONF_LINKED_REMOTES =
"linked_remotes"
77CONF_DEVICE_TYPE =
"io_device_type"
78CONF_SUBTYPE =
"io_subtype"
81CONF_MODEL =
"io_model"
82CONF_STATUS_POLL_INTERVAL =
"status_poll_interval"
85CONF_DEVICE_NAME_SENSOR_ID =
"_device_name_sensor_id"
87CONF_ACTIVE_ISSUE_SENSOR_ID =
"_active_issue_sensor_id"
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"
94CONF_LAST_COMMANDED_BY_SENSOR_ID =
"_last_commanded_by_sensor_id"
95CONF_LAST_COMMAND_SOURCE_SENSOR_ID =
"_last_command_source_sensor_id"
97IOHomeDeviceNameTextSensor = home_io_control_ns.class_(
98 "IOHomeDeviceNameTextSensor", text_sensor.TextSensor, cg.Component
100IOHomeActiveIssueTextSensor = home_io_control_ns.class_(
101 "IOHomeActiveIssueTextSensor", text_sensor.TextSensor, cg.Component
103IOHomeRssiSensor = home_io_control_ns.class_(
"IOHomeRssiSensor", sensor.Sensor, cg.Component)
104IOHomeLastContactSensor = home_io_control_ns.class_(
105 "IOHomeLastContactSensor", sensor.Sensor, cg.Component
107IOHomeExchangeFailuresSensor = home_io_control_ns.class_(
108 "IOHomeExchangeFailuresSensor", sensor.Sensor, cg.Component
110IOHomeUnconfirmedExchangesSensor = home_io_control_ns.class_(
111 "IOHomeUnconfirmedExchangesSensor", sensor.Sensor, cg.Component
113IOHomeLastCommandedByTextSensor = home_io_control_ns.class_(
114 "IOHomeLastCommandedByTextSensor", text_sensor.TextSensor, cg.Component
116IOHomeLastCommandSourceTextSensor = home_io_control_ns.class_(
117 "IOHomeLastCommandSourceTextSensor", text_sensor.TextSensor, cg.Component
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),
129 CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID,
130 "unconfirmed_exchanges_sensor",
131 IOHomeUnconfirmedExchangesSensor,
133 (CONF_LAST_COMMANDED_BY_SENSOR_ID,
"last_commanded_by_sensor", IOHomeLastCommandedByTextSensor),
134 (CONF_LAST_COMMAND_SOURCE_SENSOR_ID,
"last_command_source_sensor", IOHomeLastCommandSourceTextSensor),
139 """Derive a companion entity's name from the parent entity name and a suffix."""
140 base_name = config.get(CONF_NAME,
"")
142 return f
"{base_name} {suffix}"
147 """Return the shared ID prefix for a platform's companion entity IDs.
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.
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:.
163 from esphome.helpers
import sanitize
165 parent_id = config[parent_id_key]
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:
178 if not config[CONF_NAME]:
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."
193 return sanitize(config[CONF_NAME]).lower()
197 """Declare every auto-generated companion sensor ID during schema validation.
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().
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
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).
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.
226 value = cv._validate_entity_name(value)
229 return "" if value
is None else value
233 """Return the shared schema keys every device-bound platform extends with."""
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,
248 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
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)
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}"
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
270 """Accept io_model: only where the declared io_device_type / io_subtype offers that model.
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.
276 if CONF_MODEL
not in config:
278 model = config[CONF_MODEL]
279 if CONF_DEVICE_TYPE
not in config
or CONF_SUBTYPE
not in config:
281 f
"io_model: {model} needs io_device_type and io_subtype: a model is a product below a "
282 "device type and subtype",
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"
291 f
"io_model: {model} is not a model of {_kind_label(device_type, subtype)}; {choices}",
298 """Warn, without rejecting, that the declared kind is not yet confirmed on a real device.
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.
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)):
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,
318 """Build a post-validator that warns, without rejecting, about a key that is being retired.
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.
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.
329 def validator(config):
332 "home_io_control: '%s' uses '%s:', which is deprecated and will become a config "
334 config.get(CONF_NAME)
or config[CONF_ID].id
or "this entity",
348INSTANT_LIGHT_TRANSITION = {
349 cv.Optional(CONF_DEFAULT_TRANSITION_LENGTH, default=
"0s"): cv.positive_time_period_milliseconds
354 """The capability word of the kind an entry declares (io_device_type, io_subtype, io_model).
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.
359 if CONF_DEVICE_TYPE
not in config:
361 return device_features(config[CONF_DEVICE_TYPE], config.get(CONF_SUBTYPE, 0),
config_model(config))
367MIN_REGISTER_POLL_INTERVAL_MS = 60000
371 """Raise the status_poll_interval floor to one minute on a platform whose poll reads registers.
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.
376 interval = config.get(CONF_STATUS_POLL_INTERVAL)
377 if interval
is not None and interval.total_milliseconds < MIN_REGISTER_POLL_INTERVAL_MS:
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],
387 """Reject a YAML key the declared device kind cannot use.
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.
395 if not active
or CONF_DEVICE_TYPE
not in config:
398 if (features & required) == required
and (features & forbidden) == 0:
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}")
405 """Emit the shared to_code() wiring that binds an entity to its hub device.
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.
411 cg.add(var.set_parent(parent))
412 cg.add(var.set_device_id(config[CONF_IO_DEVICE_ID]))
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:
422 var.set_status_poll_interval(
423 config[CONF_STATUS_POLL_INTERVAL].total_milliseconds
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]))
431 if CONF_LINKED_REMOTES
in config:
432 for remote_id
in config[CONF_LINKED_REMOTES]:
433 if remote_id.startswith(
"class:"):
435 type_value = int(remote_id.split(
":", 1)[1], 16)
437 parent.add_linked_remote_class(
438 device_type_expression(type_value),
439 config[CONF_IO_DEVICE_ID],
443 cg.add(parent.add_linked_remote(remote_id, config[CONF_IO_DEVICE_ID]))
447 """Shared body for the auto-generated companion `text_sensor:` entities."""
448 companion_config = inherit_esphome_device(
452 CONF_DISABLED_BY_DEFAULT: disabled_by_default,
453 CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
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]))
464 """Shared body for the auto-generated link-health `sensor:` companions.
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.
471 if CONF_STATE_CLASS
in sensor_kwargs:
476 sensor_kwargs[CONF_STATE_CLASS] = sensor.validate_state_class(sensor_kwargs[CONF_STATE_CLASS])
478 link_health_config = inherit_esphome_device(
482 CONF_DISABLED_BY_DEFAULT:
True,
483 CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
484 CONF_FORCE_UPDATE:
False,
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]))
496 """Create and register every auto-generated companion diagnostic sensor.
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:
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.
518 config[CONF_DEVICE_NAME_SENSOR_ID],
520 disabled_by_default=
True,
525 config[CONF_ACTIVE_ISSUE_SENSOR_ID],
527 disabled_by_default=
False,
532 config[CONF_RSSI_SENSOR_ID],
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,
544 config[CONF_LAST_CONTACT_SENSOR_ID],
547 CONF_UNIT_OF_MEASUREMENT:
"s",
548 CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
549 CONF_ACCURACY_DECIMALS: 0,
555 config[CONF_EXCHANGE_FAILURES_SENSOR_ID],
558 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
559 CONF_ACCURACY_DECIMALS: 0,
565 config[CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID],
568 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
569 CONF_ACCURACY_DECIMALS: 0,
575 config[CONF_LAST_COMMANDED_BY_SENSOR_ID],
577 disabled_by_default=
True,
582 config[CONF_LAST_COMMAND_SOURCE_SENSOR_ID],
584 disabled_by_default=
True,