13import esphome.codegen
as cg
14import esphome.config_validation
as cv
15import esphome.final_validate
as fv
17from esphome.components
import button
as button_component
18from esphome.components
import text_sensor
as text_sensor_component
19from esphome.const
import (
23 ENTITY_CATEGORY_CONFIG,
24 ENTITY_CATEGORY_DIAGNOSTIC,
26from esphome.core
import ID
28from .hub_names
import (
32 CONF_ENROLLMENT_CLASSES,
33 CONF_ENROLLMENT_WITH_MAC,
34 CONF_ENROLL_BUTTON_ID,
36 CONF_EXECUTE_BROADCAST,
37 CONF_INITIAL_SEQUENCE,
39 CONF_LAST_COMMAND_SENSOR_ID,
44 CONF_ONEWAY_CONTROLLERS,
47 IOHomeOneWayCommandButton,
48 IOHomeOneWayEnrollButton,
49 IOHomeOneWayLastCommandTextSensor,
52from .hub_validators
import (
56 ONEWAY_WIRE_PROFILE_MANUFACTURERS,
58 validate_manufacturer,
63_LOGGER = logging.getLogger(__name__)
70 "open": OneWayButtonAction.OPEN,
71 "close": OneWayButtonAction.CLOSE,
72 "stop": OneWayButtonAction.STOP,
73 "vent": OneWayButtonAction.VENT,
74 "favorite": OneWayButtonAction.FAVORITE,
89 """Reject a repeated class in `enrollment_classes:` -- each just retransmits the same 0x30."""
94 (n
for n, v
in DEVICE_TYPE_OPTIONS.items()
if v == entry), hex(entry)
97 f
"enrollment_classes has '{name}' more than once; each entry adds a burst to the "
98 "enrollment gesture, so a repeat only wastes ~1 second of it"
104ONEWAY_CONTROLLER_SCHEMA = cv.Schema(
106 cv.Required(CONF_ID): cv.string_strict,
109 cv.Optional(CONF_NODE_ID): validate_node_id,
113 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
117 cv.Optional(CONF_MANUFACTURER): validate_manufacturer,
118 cv.Required(CONF_IO_DEVICE_TYPE): validate_device_type,
124 cv.Optional(CONF_INITIAL_SEQUENCE, default=0): cv.int_range(min=0, max=0xFFFF),
128 cv.Optional(CONF_COMMANDS, default=[]): cv.ensure_list(
129 cv.one_of(*ONEWAY_COMMANDS, lower=
True)
133 cv.Optional(CONF_ENROLLMENT, default=
False): cv.boolean,
143 cv.Optional(CONF_ENROLLMENT_WITH_MAC, default=
False): cv.boolean,
153 cv.Optional(CONF_ENROLLMENT_CLASSES): cv.All(
154 cv.ensure_list(cv.All(validate_device_type, cv.int_range(min=1, max=DEVICE_TYPE_MAX))),
155 cv.Length(min=1, max=3),
156 _no_duplicate_enrollment_classes,
162 cv.Optional(CONF_EXECUTE_ACEI): cv.All(cv.hex_int, cv.int_range(min=1, max=0xFF)),
166 cv.Optional(CONF_EXECUTE_BROADCAST, default=
"typed"): cv.one_of(
167 "typed",
"all", lower=
True
177 cv.Optional(CONF_LOW_POWER): cv.boolean,
183 """Derive a stable 3-byte 1W source address from the hub's node_id and an identity handle.
185 `node_id` is optional on a `oneway_controllers:` entry because asking a user to invent a
186 3-byte radio address is an unanswerable question — nothing tells them which addresses are
187 safe, and colliding with a real remote in range silently desyncs both transmitters' rolling
188 sequence counters. Deriving one removes the decision while leaving an explicit value
189 available to anyone who needs it.
191 The derivation is done here, at schema time, rather than at runtime on the device, so that a
192 derived address participates in the same collision checks as a configured one and a clash
193 fails the build instead of surfacing as a device that silently ignores commands. It is a
194 pure function of (hub node_id, identity id), so it is stable across builds and reproducible
197 Uses BLAKE2b rather than Python's hash(), which is salted per-process and would produce a
198 different address on every compile.
200 digest = hashlib.blake2b(
201 f
"{hub_node_id}:{identity_id}".encode(), digest_size=3
203 return f
"{digest[0]:02X}{digest[1]:02X}{digest[2]:02X}"
207 """Render a hex string as a C++ brace-initialiser list of bytes."""
209 f
"0x{hex_string[i : i + 2]}" for i
in range(0, len(hex_string), 2)
211 return f
"{{{values}}}"
215 """Render `enrollment_classes:` as a 3-element std::array<DeviceType,3> brace-initialiser.
217 Unset, or fewer than 3, is padded with UNKNOWN (0x00) -- the sentinel effective_enrollment_classes()
218 (oneway_controller.h) reads as "not overridden here" / "skip this slot".
220 padded = (list(identity.get(CONF_ENROLLMENT_CLASSES, [])) + [0, 0, 0])[:3]
222 f
"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
225 return f
"{{{entries}}}"
229 """Map `low_power:` to the C++ `std::optional<OneWayPowerClass>` the identity stores.
231 Tri-state, and the three states are emitted as three distinct values rather than collapsed
232 here: unset -> `{}` (empty optional), `false` -> ALWAYS_ALIVE, `true` -> LOW_POWER.
234 An unset key deliberately does **not** pick a shape at codegen time. Resolving it needs the
235 manufacturer profile, which lives in C++ (`resolve_oneway_wire_profile()`), and splitting that
236 lookup across two languages is how the ACEI and enrollment-class defaults would drift from this
237 one. `effective_power_class()` is the single resolver. See ADR 0041, and ADR 0038 for the
240 if CONF_LOW_POWER
not in identity:
242 name =
"LOW_POWER" if identity[CONF_LOW_POWER]
else "ALWAYS_ALIVE"
243 return f
"esphome::home_io_control::OneWayPowerClass::{name}"
247 """Generate the C++ OneWayControllerIdentity initialiser for one configured identity.
249 Emitted as a designated initialiser so the generated code reads like the YAML that produced
250 it, and so adding a field to the struct cannot silently shift an existing value.
252 derived = identity.get(CONF_NODE_ID_DERIVED,
False)
255 f
'.id = "{identity[CONF_ID]}"',
256 f
".node_id = {_hex_byte_array(identity[CONF_NODE_ID])}",
257 f
".system_key = {_hex_byte_array(identity[CONF_SYSTEM_KEY])}",
258 f
".manufacturer = 0x{identity[CONF_MANUFACTURER]:02X}",
259 f
".io_device_type = static_cast<esphome::home_io_control::DeviceType>"
260 f
"(0x{identity[CONF_IO_DEVICE_TYPE]:02X})",
261 f
".initial_sequence = 0x{identity[CONF_INITIAL_SEQUENCE]:04X}",
262 f
".node_id_derived = {'true' if derived else 'false'}",
263 f
".enrollment_with_mac = {'true' if identity[CONF_ENROLLMENT_WITH_MAC] else 'false'}",
264 f
".execute_acei = 0x{identity.get(CONF_EXECUTE_ACEI, 0):02X}",
265 f
".execute_broadcast_all = "
266 f
"{'true' if identity[CONF_EXECUTE_BROADCAST] == 'all' else 'false'}",
267 f
".enrollment_classes = {_enrollment_classes_initialiser(identity)}",
268 f
".power_class_override = {_power_class_override_expression(identity)}",
273 "home_io_control: oneway_controllers '%s' node_id derived from hub %s -> %s",
276 identity[CONF_NODE_ID],
278 return cg.RawExpression(
279 f
"esphome::home_io_control::OneWayControllerIdentity{{{fields}}}"
284 """Raise if `node_id` is already claimed in `seen_node_ids`; no-op otherwise.
286 Shared between validate_oneway_controllers() (collisions against the hub's own node_id and
287 other oneway_controllers entries) and final_validate_oneway_controller_addresses()
288 (collisions against `linked_remotes:`/`io_device_id:` declared elsewhere in the same YAML),
289 so both raise identically-worded errors regardless of which side of the config the other
292 if node_id
not in seen_node_ids:
294 owner = seen_node_ids[node_id]
296 " (this address was derived; set node_id: explicitly to resolve the clash)"
301 f
"oneway_controllers id '{identity_id}' uses node_id {node_id}, which collides with "
302 f
"{owner}{hint}. Two transmitters sharing an address share a rolling sequence "
303 f
"counter, which silently desyncs both."
308 """Resolve per-identity defaults and reject address/handle collisions at compile time.
310 Runs as a post-validator on the whole hub config because every rule here needs the hub's own
311 `node_id`/`system_key`, which a per-entry validator cannot see.
313 Only checks addresses visible within `home_io_control:` itself (its own `node_id` and every
314 configured `oneway_controllers` entry) — see final_validate_oneway_controller_addresses()
315 below for the matching check against `linked_remotes:`/`io_device_id:` declared elsewhere in
316 the same YAML, which needs the full cross-component config and so cannot run here. Even
317 together the two cannot see a real remote the user has never mentioned to this config at all;
318 see derive_oneway_node_id()'s own docstring for why that residual risk cannot be validated
321 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
325 hub_node_id = config[CONF_NODE_ID]
329 seen_node_ids = {hub_node_id:
"the hub's own node_id"}
331 for identity
in identities:
332 identity_id = identity[CONF_ID]
333 if identity_id
in seen_ids:
335 f
"Duplicate oneway_controllers id '{identity_id}' — each identity needs its own handle"
337 seen_ids.add(identity_id)
339 if CONF_NODE_ID
not in identity:
341 identity[CONF_NODE_ID_DERIVED] =
True
343 node_id = identity[CONF_NODE_ID]
345 seen_node_ids[node_id] = f
"oneway_controllers id '{identity_id}'"
349 if CONF_SYSTEM_KEY
not in identity:
350 identity[CONF_SYSTEM_KEY] = config[CONF_SYSTEM_KEY]
356 if identity[CONF_ENROLLMENT]
and CONF_MANUFACTURER
not in identity:
358 f
"oneway_controllers id '{identity_id}' has enrollment: true but no manufacturer: "
359 "set. Find the value from a key-adoption report for this network (the 'Recover "
360 "1W Controller Key' switch prints it), or from the device's own documentation."
365 manufacturer_explicit = CONF_MANUFACTURER
in identity
366 if CONF_MANUFACTURER
not in identity:
367 identity[CONF_MANUFACTURER] = 0
375 identity_can_transmit = bool(identity[CONF_COMMANDS])
or identity[CONF_ENROLLMENT]
377 manufacturer_explicit
378 and identity[CONF_MANUFACTURER]
not in ONEWAY_WIRE_PROFILE_MANUFACTURERS
379 and identity_can_transmit
382 "home_io_control: oneway_controllers '%s' has manufacturer 0x%02X, which has no "
383 "1W wire profile -- using the Somfy-shaped defaults (ACEI 0x43). Set execute_acei: "
384 "explicitly if that is wrong for your device.",
386 identity[CONF_MANUFACTURER],
393 identity[CONF_ENROLLMENT]
394 and identity[CONF_ENROLLMENT_WITH_MAC]
395 and config[CONF_RADIO_TYPE] ==
"sx1276"
398 "home_io_control: oneway_controllers '%s' has enrollment_with_mac: true on "
399 "radio_type: sx1276, where the MAC-bearing 0x30 has been measured to take about "
400 "twice as long per burst. No VELUX capture carries this MAC either. Consider "
401 "leaving it false unless your hardware specifically needs the MAC-bearing form.",
412 identity[CONF_MANUFACTURER] == MANUFACTURER_OPTIONS[
"velux"]
413 and identity[CONF_ENROLLMENT]
414 and CONF_ENROLLMENT_CLASSES
not in identity
415 and identity[CONF_IO_DEVICE_TYPE]
417 DEVICE_TYPE_OPTIONS[
"vertical_interior_blind"],
418 DEVICE_TYPE_OPTIONS[
"interior_venetian_blind"],
422 "home_io_control: oneway_controllers '%s' is a VELUX %s with enrollment: true, but "
423 "enrollment_classes: is unset, so the enrollment 0x30 sweep uses the exterior-shading "
424 "default roller_shutter/vertical_exterior_awning/dual_roller_shutter and ignores "
425 "io_device_type. This device may enroll on other classes: set enrollment_classes: (a KLI "
426 "312 interior blind uses [vertical_interior_blind, interior_venetian_blind]). To find "
427 "yours, press Gear on the existing remote and use the classes named in its 'rx 1W "
428 "remote ... (0x2E)' DEBUG log lines -- see 'Finding your enrollment classes' in the "
433 for name, value
in DEVICE_TYPE_OPTIONS.items()
434 if value == identity[CONF_IO_DEVICE_TYPE]
441 if CONF_ENROLLMENT_CLASSES
in identity
and (
442 identity[CONF_MANUFACTURER] != MANUFACTURER_OPTIONS[
"velux"]
443 or not identity[CONF_ENROLLMENT]
446 "home_io_control: oneway_controllers '%s' sets enrollment_classes: but it only "
447 "affects the VELUX enrollment gesture (needs manufacturer: velux AND "
448 "enrollment: true) -- it is ignored here.",
456 identity[CONF_BUTTON_IDS] = {
458 f
"{identity_id}_{command}",
460 type=IOHomeOneWayCommandButton,
462 for command
in identity[CONF_COMMANDS]
464 identity[CONF_LAST_COMMAND_SENSOR_ID] = ID(
465 f
"{identity_id}_last_1w_command",
467 type=IOHomeOneWayLastCommandTextSensor,
469 if identity[CONF_ENROLLMENT]:
470 identity[CONF_ENROLL_BUTTON_ID] = ID(
471 f
"{identity_id}_enroll",
473 type=IOHomeOneWayEnrollButton,
482_DEVICE_BOUND_DOMAINS = (
"cover",
"light",
"lock",
"switch")
486 """Map every node ID declared as an `io_device_id:` or a bare `linked_remotes:` entry, across
487 every `home_io_control` entity in `full_config`, to a human-readable owner string.
489 Only entries with `platform: home_io_control` are considered — the domains in
490 _DEVICE_BOUND_DOMAINS are shared with every other component that provides a cover/light/
491 lock/switch platform, and those have nothing to do with this component's address space.
492 `class:` linked_remotes entries name a device *type*, not a node, so they carry no address to
493 collide with and are skipped.
495 CONF_IO_DEVICE_ID/CONF_LINKED_REMOTES are imported locally from platform_common rather than at
496 module level: platform_common imports from the package (`from . import ...`), whose
497 __init__.py imports this module, so a module-level import here would be circular. By the time
498 this function actually runs (final validation, after every used platform module has already
499 been imported), the cycle has already resolved and the import is a plain cache hit.
501 from .platform_common
import CONF_IO_DEVICE_ID, CONF_LINKED_REMOTES
504 for domain
in _DEVICE_BOUND_DOMAINS:
505 for entry
in full_config.get(domain, []):
506 if not isinstance(entry, dict)
or entry.get(CONF_PLATFORM) !=
"home_io_control":
508 owner_name = entry.get(CONF_NAME)
or entry.get(CONF_ID)
or "<unnamed>"
509 device_id = entry.get(CONF_IO_DEVICE_ID)
511 addresses[device_id] = f
"{domain} '{owner_name}' io_device_id"
512 for remote
in entry.get(CONF_LINKED_REMOTES, []):
513 if remote.startswith(
"class:"):
515 addresses[remote] = f
"{domain} '{owner_name}' linked_remotes"
520 """Extend the oneway_controllers address-collision check to addresses declared outside
521 `home_io_control:` — a `linked_remotes:` entry or an `io_device_id:` on some other entity in
524 Runs as FINAL_VALIDATE_SCHEMA rather than inside validate_oneway_controllers() because only
525 final validation has access to the full cross-component config (`fv.full_config`) —
526 `cover:`/`light:`/`lock:`/`switch:` entries are validated independently of
527 `home_io_control:`'s own CONFIG_SCHEMA and are not visible to it. By this point
528 validate_oneway_controllers() has already run, so every identity's `node_id` (derived or
529 explicit) is resolved.
531 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
536 for identity
in identities:
538 identity[CONF_ID], identity[CONF_NODE_ID], declared, identity.get(CONF_NODE_ID_DERIVED)
544 """Create one identity's command buttons and its "Last 1W Command" diagnostic sensor.
546 Same normalization as create_hub_arming_switch() (hub_entities.py): run a bare {id, name}
547 dict through the platform's own schema so it carries the entity/component defaults register_*() require.
549 Entity names derive from the identity handle and the command ("awning_remote" + "open" ->
550 "Awning Remote Open"), mirroring how the cover's favourite/vent companions derive theirs. The
551 *IDs* follow the documented `<identity_id>_<command>` rule instead, because those are what a
552 `time_based` cover composes against.
554 friendly_identity = identity[CONF_ID].replace(
"_",
" ").title()
556 for command, button_id
in identity[CONF_BUTTON_IDS].items():
557 entity_config = button_component.button_schema(
558 IOHomeOneWayCommandButton,
559 ).extend(cv.COMPONENT_SCHEMA)(
562 CONF_NAME: f
"{friendly_identity} {command.replace('_', ' ').title()}",
565 entity = await button_component.new_button(entity_config)
566 await cg.register_component(entity, entity_config)
567 cg.add(entity.set_parent(var))
568 cg.add(entity.set_controller_id(identity[CONF_ID]))
569 cg.add(entity.set_action(ONEWAY_COMMANDS[command]))
574 sensor_config = text_sensor_component.text_sensor_schema(
575 IOHomeOneWayLastCommandTextSensor,
576 entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
577 ).extend(cv.COMPONENT_SCHEMA)(
579 CONF_ID: identity[CONF_LAST_COMMAND_SENSOR_ID],
580 CONF_NAME: f
"{friendly_identity} Last 1W Command",
583 sensor = await text_sensor_component.new_text_sensor(sensor_config)
584 await cg.register_component(sensor, sensor_config)
585 cg.add(sensor.set_parent(var))
586 cg.add(sensor.set_controller_id(identity[CONF_ID]))
588 if identity[CONF_ENROLLMENT]:
599 enroll_config = button_component.button_schema(
600 IOHomeOneWayEnrollButton,
601 entity_category=ENTITY_CATEGORY_CONFIG,
602 ).extend(cv.COMPONENT_SCHEMA)(
604 CONF_ID: identity[CONF_ENROLL_BUTTON_ID],
605 CONF_NAME: f
"{friendly_identity} Enroll 1W Controller",
608 enroll_entity = await button_component.new_button(enroll_config)
609 await cg.register_component(enroll_entity, enroll_config)
610 cg.add(enroll_entity.set_parent(var))
611 cg.add(enroll_entity.set_controller_id(identity[CONF_ID]))
_no_duplicate_enrollment_classes(value)
_collect_declared_device_addresses(full_config)
final_validate_oneway_controller_addresses(config)
validate_oneway_controllers(config)
derive_oneway_node_id(hub_node_id, identity_id)
oneway_controller_expression(identity, hub_node_id)
_power_class_override_expression(identity)
_enrollment_classes_initialiser(identity)
_hex_byte_array(hex_string)
_reject_node_id_collision(identity_id, node_id, seen_node_ids, derived)
create_oneway_controller_entities(identity, var)