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,
22 CONF_DISABLED_BY_DEFAULT,
28 CONF_UNIT_OF_MEASUREMENT,
29 ENTITY_CATEGORY_DIAGNOSTIC,
30 STATE_CLASS_MEASUREMENT,
31 STATE_CLASS_TOTAL_INCREASING,
33from esphome.components.sensor
import DEVICE_CLASS_SIGNAL_STRENGTH
34from esphome.core
import ID
38 IOHomeControlComponent,
39 CONF_HOME_IO_CONTROL_ID,
41 device_type_expression,
42 inherit_esphome_device,
45 validate_linked_remote_entry,
46 validate_status_poll_interval,
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"
60CONF_DEVICE_NAME_SENSOR_ID =
"_device_name_sensor_id"
62CONF_ACTIVE_ISSUE_SENSOR_ID =
"_active_issue_sensor_id"
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"
69CONF_LAST_COMMANDED_BY_SENSOR_ID =
"_last_commanded_by_sensor_id"
70CONF_LAST_COMMAND_SOURCE_SENSOR_ID =
"_last_command_source_sensor_id"
72IOHomeDeviceNameTextSensor = home_io_control_ns.class_(
73 "IOHomeDeviceNameTextSensor", text_sensor.TextSensor, cg.Component
75IOHomeActiveIssueTextSensor = home_io_control_ns.class_(
76 "IOHomeActiveIssueTextSensor", text_sensor.TextSensor, cg.Component
78IOHomeRssiSensor = home_io_control_ns.class_(
"IOHomeRssiSensor", sensor.Sensor, cg.Component)
79IOHomeLastContactSensor = home_io_control_ns.class_(
80 "IOHomeLastContactSensor", sensor.Sensor, cg.Component
82IOHomeExchangeFailuresSensor = home_io_control_ns.class_(
83 "IOHomeExchangeFailuresSensor", sensor.Sensor, cg.Component
85IOHomeUnconfirmedExchangesSensor = home_io_control_ns.class_(
86 "IOHomeUnconfirmedExchangesSensor", sensor.Sensor, cg.Component
88IOHomeLastCommandedByTextSensor = home_io_control_ns.class_(
89 "IOHomeLastCommandedByTextSensor", text_sensor.TextSensor, cg.Component
91IOHomeLastCommandSourceTextSensor = home_io_control_ns.class_(
92 "IOHomeLastCommandSourceTextSensor", text_sensor.TextSensor, cg.Component
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),
104 CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID,
105 "unconfirmed_exchanges_sensor",
106 IOHomeUnconfirmedExchangesSensor,
108 (CONF_LAST_COMMANDED_BY_SENSOR_ID,
"last_commanded_by_sensor", IOHomeLastCommandedByTextSensor),
109 (CONF_LAST_COMMAND_SOURCE_SENSOR_ID,
"last_command_source_sensor", IOHomeLastCommandSourceTextSensor),
114 """Derive a companion sensor's entity name from the parent entity name."""
115 base_name = config.get(CONF_NAME,
"")
117 return f
"{base_name} {suffix}"
122 """Return the shared ID prefix for a platform's companion entity IDs.
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.
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:.
138 from esphome.helpers
import sanitize
140 parent_id = config[parent_id_key]
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:
153 if not config[CONF_NAME]:
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."
168 return sanitize(config[CONF_NAME]).lower()
172 """Declare every auto-generated companion sensor ID during schema validation.
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().
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
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).
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.
201 value = cv._validate_entity_name(value)
204 return "" if value
is None else value
208 """Return the shared schema keys every device-bound platform extends with."""
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,
222 """Emit the shared to_code() wiring that binds an entity to its hub device.
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.
228 cg.add(var.set_parent(parent))
229 cg.add(var.set_device_id(config[CONF_IO_DEVICE_ID]))
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:
237 var.set_status_poll_interval(
238 config[CONF_STATUS_POLL_INTERVAL].total_milliseconds
241 if CONF_LOW_POWER
in config:
242 cg.add(var.set_low_power(config[CONF_LOW_POWER]))
244 if CONF_LINKED_REMOTES
in config:
245 for remote_id
in config[CONF_LINKED_REMOTES]:
246 if remote_id.startswith(
"class:"):
248 type_value = int(remote_id.split(
":", 1)[1], 16)
250 parent.add_linked_remote_class(
251 device_type_expression(type_value),
252 config[CONF_IO_DEVICE_ID],
256 cg.add(parent.add_linked_remote(remote_id, config[CONF_IO_DEVICE_ID]))
260 """Shared body for the auto-generated companion `text_sensor:` entities."""
261 companion_config = inherit_esphome_device(
265 CONF_DISABLED_BY_DEFAULT: disabled_by_default,
266 CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
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]))
277 """Shared body for the auto-generated link-health `sensor:` companions.
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.
284 if CONF_STATE_CLASS
in sensor_kwargs:
289 sensor_kwargs[CONF_STATE_CLASS] = sensor.validate_state_class(sensor_kwargs[CONF_STATE_CLASS])
291 link_health_config = inherit_esphome_device(
295 CONF_DISABLED_BY_DEFAULT:
True,
296 CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_DIAGNOSTIC,
297 CONF_FORCE_UPDATE:
False,
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]))
309 """Create and register every auto-generated companion diagnostic sensor.
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:
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.
330 config[CONF_DEVICE_NAME_SENSOR_ID],
332 disabled_by_default=
True,
337 config[CONF_ACTIVE_ISSUE_SENSOR_ID],
339 disabled_by_default=
False,
344 config[CONF_RSSI_SENSOR_ID],
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,
356 config[CONF_LAST_CONTACT_SENSOR_ID],
359 CONF_UNIT_OF_MEASUREMENT:
"s",
360 CONF_STATE_CLASS: STATE_CLASS_MEASUREMENT,
361 CONF_ACCURACY_DECIMALS: 0,
367 config[CONF_EXCHANGE_FAILURES_SENSOR_ID],
370 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
371 CONF_ACCURACY_DECIMALS: 0,
377 config[CONF_UNCONFIRMED_EXCHANGES_SENSOR_ID],
380 CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING,
381 CONF_ACCURACY_DECIMALS: 0,
387 config[CONF_LAST_COMMANDED_BY_SENSOR_ID],
389 disabled_by_default=
True,
394 config[CONF_LAST_COMMAND_SOURCE_SENSOR_ID],
396 disabled_by_default=
True,