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,
40 CONF_LAST_COMMAND_SENSOR_ID,
45 CONF_ONEWAY_CONTROLLERS,
48 IOHomeOneWayCommandButton,
49 IOHomeOneWayEnrollButton,
50 IOHomeOneWayLastCommandTextSensor,
53from .hub_validators
import (
58 ONEWAY_WIRE_PROFILE_MANUFACTURERS,
61 validate_manufacturer,
66_LOGGER = logging.getLogger(__name__)
73 "open": OneWayButtonAction.OPEN,
74 "close": OneWayButtonAction.CLOSE,
75 "stop": OneWayButtonAction.STOP,
76 "vent": OneWayButtonAction.VENT,
77 "favorite": OneWayButtonAction.FAVORITE,
92 """Reject a repeated class in `enrollment_classes:` -- each just retransmits the same 0x30."""
97 (n
for n, v
in DEVICE_TYPE_OPTIONS.items()
if v == entry), hex(entry)
100 f
"enrollment_classes has '{name}' more than once; each entry adds a burst to the "
101 "enrollment gesture, so a repeat only wastes ~1 second of it"
107ONEWAY_CONTROLLER_SCHEMA = cv.Schema(
109 cv.Required(CONF_ID): cv.string_strict,
112 cv.Optional(CONF_NODE_ID): validate_node_id,
116 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
120 cv.Optional(CONF_MANUFACTURER): validate_manufacturer,
121 cv.Required(CONF_IO_DEVICE_TYPE): validate_device_type,
127 cv.Optional(CONF_INITIAL_SEQUENCE, default=0): cv.int_range(min=0, max=0xFFFF),
131 cv.Optional(CONF_COMMANDS, default=[]): cv.ensure_list(
132 cv.one_of(*ONEWAY_COMMANDS, lower=
True)
136 cv.Optional(CONF_ENROLLMENT, default=
False): cv.boolean,
146 cv.Optional(CONF_ENROLLMENT_WITH_MAC, default=
False): cv.boolean,
156 cv.Optional(CONF_ENROLLMENT_CLASSES): cv.All(
157 cv.ensure_list(cv.All(validate_device_type, cv.int_range(min=1, max=DEVICE_TYPE_MAX))),
158 cv.Length(min=1, max=3),
159 _no_duplicate_enrollment_classes,
165 cv.Optional(CONF_EXECUTE_ACEI): cv.All(cv.hex_int, cv.int_range(min=1, max=0xFF)),
169 cv.Optional(CONF_EXECUTE_BROADCAST, default=
"typed"): cv.one_of(
170 "typed",
"all", lower=
True
180 cv.Optional(CONF_LOW_POWER): cv.boolean,
184 cv.Optional(CONF_INVERT_POSITION): cv.boolean,
190 """Derive a stable 3-byte 1W source address from the hub's node_id and an identity handle.
192 `node_id` is optional on a `oneway_controllers:` entry because asking a user to invent a
193 3-byte radio address is an unanswerable question — nothing tells them which addresses are
194 safe, and colliding with a real remote in range silently desyncs both transmitters' rolling
195 sequence counters. Deriving one removes the decision while leaving an explicit value
196 available to anyone who needs it.
198 The derivation is done here, at schema time, rather than at runtime on the device, so that a
199 derived address participates in the same collision checks as a configured one and a clash
200 fails the build instead of surfacing as a device that silently ignores commands. It is a
201 pure function of (hub node_id, identity id), so it is stable across builds and reproducible
204 Uses BLAKE2b rather than Python's hash(), which is salted per-process and would produce a
205 different address on every compile.
207 digest = hashlib.blake2b(
208 f
"{hub_node_id}:{identity_id}".encode(), digest_size=3
210 return f
"{digest[0]:02X}{digest[1]:02X}{digest[2]:02X}"
214 """Render a hex string as a C++ brace-initialiser list of bytes."""
216 f
"0x{hex_string[i : i + 2]}" for i
in range(0, len(hex_string), 2)
218 return f
"{{{values}}}"
222 """Render `enrollment_classes:` as a 3-element std::array<DeviceType,3> brace-initialiser.
224 Unset, or fewer than 3, is padded with UNKNOWN (0x00) -- the sentinel effective_enrollment_classes()
225 (oneway_controller.h) reads as "not overridden here" / "skip this slot".
227 padded = (list(identity.get(CONF_ENROLLMENT_CLASSES, [])) + [0, 0, 0])[:3]
229 f
"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
232 return f
"{{{entries}}}"
236 """Map `low_power:` to the C++ `std::optional<OneWayPowerClass>` the identity stores.
238 Tri-state, and the three states are emitted as three distinct values rather than collapsed
239 here: unset -> `{}` (empty optional), `false` -> ALWAYS_ALIVE, `true` -> LOW_POWER.
241 An unset key deliberately does **not** pick a shape at codegen time. Resolving it needs the
242 manufacturer profile, which lives in C++ (`resolve_oneway_wire_profile()`), and splitting that
243 lookup across two languages is how the ACEI and enrollment-class defaults would drift from this
244 one. `effective_power_class()` is the single resolver. See ADR 0041, and ADR 0038 for the
247 if CONF_LOW_POWER
not in identity:
249 name =
"LOW_POWER" if identity[CONF_LOW_POWER]
else "ALWAYS_ALIVE"
250 return f
"esphome::home_io_control::OneWayPowerClass::{name}"
254 """Map `invert_position:` to the C++ `std::optional<bool>` the identity stores.
256 Unset -> `{}`: the kind's INVERTED capability bit is looked up in C++ by
257 `effective_inverted()`, so the Python side never carries a copy of the type table.
259 if CONF_INVERT_POSITION
not in identity:
261 return "true" if identity[CONF_INVERT_POSITION]
else "false"
265 """Generate the C++ OneWayControllerIdentity initialiser for one configured identity.
267 Emitted as a designated initialiser so the generated code reads like the YAML that produced
268 it, and so adding a field to the struct cannot silently shift an existing value.
270 derived = identity.get(CONF_NODE_ID_DERIVED,
False)
273 f
'.id = "{identity[CONF_ID]}"',
274 f
".node_id = {_hex_byte_array(identity[CONF_NODE_ID])}",
275 f
".system_key = {_hex_byte_array(identity[CONF_SYSTEM_KEY])}",
276 f
".manufacturer = 0x{identity[CONF_MANUFACTURER]:02X}",
277 f
".io_device_type = static_cast<esphome::home_io_control::DeviceType>"
278 f
"(0x{identity[CONF_IO_DEVICE_TYPE]:02X})",
279 f
".initial_sequence = 0x{identity[CONF_INITIAL_SEQUENCE]:04X}",
280 f
".node_id_derived = {'true' if derived else 'false'}",
281 f
".enrollment_with_mac = {'true' if identity[CONF_ENROLLMENT_WITH_MAC] else 'false'}",
282 f
".execute_acei = 0x{identity.get(CONF_EXECUTE_ACEI, 0):02X}",
283 f
".execute_broadcast_all = "
284 f
"{'true' if identity[CONF_EXECUTE_BROADCAST] == 'all' else 'false'}",
285 f
".enrollment_classes = {_enrollment_classes_initialiser(identity)}",
286 f
".power_class_override = {_power_class_override_expression(identity)}",
287 f
".invert_override = {_invert_override_expression(identity)}",
292 "home_io_control: oneway_controllers '%s' node_id derived from hub %s -> %s",
295 identity[CONF_NODE_ID],
297 return cg.RawExpression(
298 f
"esphome::home_io_control::OneWayControllerIdentity{{{fields}}}"
303 """Raise if `node_id` is already claimed in `seen_node_ids`; no-op otherwise.
305 Shared between validate_oneway_controllers() (collisions against the hub's own node_id and
306 other oneway_controllers entries) and final_validate_oneway_controller_addresses()
307 (collisions against `linked_remotes:`/`io_device_id:` declared elsewhere in the same YAML),
308 so both raise identically-worded errors regardless of which side of the config the other
311 if node_id
not in seen_node_ids:
313 owner = seen_node_ids[node_id]
315 " (this address was derived; set node_id: explicitly to resolve the clash)"
320 f
"oneway_controllers id '{identity_id}' uses node_id {node_id}, which collides with "
321 f
"{owner}{hint}. Two transmitters sharing an address share a rolling sequence "
322 f
"counter, which silently desyncs both."
327 """Resolve per-identity defaults and reject address/handle collisions at compile time.
329 Runs as a post-validator on the whole hub config because every rule here needs the hub's own
330 `node_id`/`system_key`, which a per-entry validator cannot see.
332 Only checks addresses visible within `home_io_control:` itself (its own `node_id` and every
333 configured `oneway_controllers` entry) — see final_validate_oneway_controller_addresses()
334 below for the matching check against `linked_remotes:`/`io_device_id:` declared elsewhere in
335 the same YAML, which needs the full cross-component config and so cannot run here. Even
336 together the two cannot see a real remote the user has never mentioned to this config at all;
337 see derive_oneway_node_id()'s own docstring for why that residual risk cannot be validated
340 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
344 hub_node_id = config[CONF_NODE_ID]
348 seen_node_ids = {hub_node_id:
"the hub's own node_id"}
350 for identity
in identities:
351 identity_id = identity[CONF_ID]
352 if identity_id
in seen_ids:
354 f
"Duplicate oneway_controllers id '{identity_id}' — each identity needs its own handle"
356 seen_ids.add(identity_id)
358 if CONF_NODE_ID
not in identity:
360 identity[CONF_NODE_ID_DERIVED] =
True
362 node_id = identity[CONF_NODE_ID]
364 seen_node_ids[node_id] = f
"oneway_controllers id '{identity_id}'"
368 if CONF_SYSTEM_KEY
not in identity:
369 identity[CONF_SYSTEM_KEY] = config[CONF_SYSTEM_KEY]
375 if identity[CONF_ENROLLMENT]
and CONF_MANUFACTURER
not in identity:
377 f
"oneway_controllers id '{identity_id}' has enrollment: true but no manufacturer: "
378 "set. Find the value from a key-adoption report for this network (the 'Recover "
379 "1W Controller Key' switch prints it), or from the device's own documentation."
384 manufacturer_explicit = CONF_MANUFACTURER
in identity
385 if CONF_MANUFACTURER
not in identity:
386 identity[CONF_MANUFACTURER] = 0
394 identity_can_transmit = bool(identity[CONF_COMMANDS])
or identity[CONF_ENROLLMENT]
396 manufacturer_explicit
397 and identity[CONF_MANUFACTURER]
not in ONEWAY_WIRE_PROFILE_MANUFACTURERS
398 and identity_can_transmit
401 "home_io_control: oneway_controllers '%s' has manufacturer 0x%02X, which has no "
402 "1W wire profile -- using the Somfy-shaped defaults (ACEI 0x43). Set execute_acei: "
403 "explicitly if that is wrong for your device.",
405 identity[CONF_MANUFACTURER],
412 identity[CONF_ENROLLMENT]
413 and identity[CONF_ENROLLMENT_WITH_MAC]
414 and config[CONF_RADIO_TYPE] ==
"sx1276"
417 "home_io_control: oneway_controllers '%s' has enrollment_with_mac: true on "
418 "radio_type: sx1276, where the MAC-bearing 0x30 has been measured to take about "
419 "twice as long per burst. No VELUX capture carries this MAC either. Consider "
420 "leaving it false unless your hardware specifically needs the MAC-bearing form.",
431 identity[CONF_MANUFACTURER] == MANUFACTURER_OPTIONS[
"velux"]
432 and identity[CONF_ENROLLMENT]
433 and CONF_ENROLLMENT_CLASSES
not in identity
434 and identity[CONF_IO_DEVICE_TYPE]
436 DEVICE_TYPE_OPTIONS[
"vertical_interior_blind"],
437 DEVICE_TYPE_OPTIONS[
"interior_venetian_blind"],
441 "home_io_control: oneway_controllers '%s' is a VELUX %s with enrollment: true, but "
442 "enrollment_classes: is unset, so the enrollment 0x30 sweep uses the exterior-shading "
443 "default roller_shutter/vertical_exterior_awning/dual_roller_shutter and ignores "
444 "io_device_type. This device may enroll on other classes: set enrollment_classes: (a KLI "
445 "312 interior blind uses [vertical_interior_blind, interior_venetian_blind]). To find "
446 "yours, press Gear on the existing remote and use the classes named in its 'rx 1W "
447 "remote ... (0x2E)' DEBUG log lines -- see 'Finding your enrollment classes' in the "
452 for name, value
in DEVICE_TYPE_OPTIONS.items()
453 if value == identity[CONF_IO_DEVICE_TYPE]
460 identity_can_transmit
461 and CONF_INVERT_POSITION
not in identity
462 and identity[CONF_EXECUTE_BROADCAST] ==
"all"
463 and device_features(identity[CONF_IO_DEVICE_TYPE]) & FEATURE_INVERTED
466 "home_io_control: oneway_controllers '%s' is an inverted device kind with "
467 "execute_broadcast: all -- open/close are mirrored for every receiver of this "
468 "broadcast; set invert_position explicitly if this identity also drives "
469 "non-awning devices.",
476 if CONF_ENROLLMENT_CLASSES
in identity
and (
477 identity[CONF_MANUFACTURER] != MANUFACTURER_OPTIONS[
"velux"]
478 or not identity[CONF_ENROLLMENT]
481 "home_io_control: oneway_controllers '%s' sets enrollment_classes: but it only "
482 "affects the VELUX enrollment gesture (needs manufacturer: velux AND "
483 "enrollment: true) -- it is ignored here.",
491 identity[CONF_BUTTON_IDS] = {
493 f
"{identity_id}_{command}",
495 type=IOHomeOneWayCommandButton,
497 for command
in identity[CONF_COMMANDS]
499 identity[CONF_LAST_COMMAND_SENSOR_ID] = ID(
500 f
"{identity_id}_last_1w_command",
502 type=IOHomeOneWayLastCommandTextSensor,
504 if identity[CONF_ENROLLMENT]:
505 identity[CONF_ENROLL_BUTTON_ID] = ID(
506 f
"{identity_id}_enroll",
508 type=IOHomeOneWayEnrollButton,
517_DEVICE_BOUND_DOMAINS = (
"cover",
"light",
"lock",
"switch")
521 """Map every node ID declared as an `io_device_id:` or a bare `linked_remotes:` entry, across
522 every `home_io_control` entity in `full_config`, to a human-readable owner string.
524 Only entries with `platform: home_io_control` are considered — the domains in
525 _DEVICE_BOUND_DOMAINS are shared with every other component that provides a cover/light/
526 lock/switch platform, and those have nothing to do with this component's address space.
527 `class:` linked_remotes entries name a device *type*, not a node, so they carry no address to
528 collide with and are skipped.
530 CONF_IO_DEVICE_ID/CONF_LINKED_REMOTES are imported locally from platform_common rather than at
531 module level: platform_common imports from the package (`from . import ...`), whose
532 __init__.py imports this module, so a module-level import here would be circular. By the time
533 this function actually runs (final validation, after every used platform module has already
534 been imported), the cycle has already resolved and the import is a plain cache hit.
536 from .platform_common
import CONF_IO_DEVICE_ID, CONF_LINKED_REMOTES
539 for domain
in _DEVICE_BOUND_DOMAINS:
540 for entry
in full_config.get(domain, []):
541 if not isinstance(entry, dict)
or entry.get(CONF_PLATFORM) !=
"home_io_control":
543 owner_name = entry.get(CONF_NAME)
or entry.get(CONF_ID)
or "<unnamed>"
544 device_id = entry.get(CONF_IO_DEVICE_ID)
546 addresses[device_id] = f
"{domain} '{owner_name}' io_device_id"
547 for remote
in entry.get(CONF_LINKED_REMOTES, []):
548 if remote.startswith(
"class:"):
550 addresses[remote] = f
"{domain} '{owner_name}' linked_remotes"
555 """Extend the oneway_controllers address-collision check to addresses declared outside
556 `home_io_control:` — a `linked_remotes:` entry or an `io_device_id:` on some other entity in
559 Runs as FINAL_VALIDATE_SCHEMA rather than inside validate_oneway_controllers() because only
560 final validation has access to the full cross-component config (`fv.full_config`) —
561 `cover:`/`light:`/`lock:`/`switch:` entries are validated independently of
562 `home_io_control:`'s own CONFIG_SCHEMA and are not visible to it. By this point
563 validate_oneway_controllers() has already run, so every identity's `node_id` (derived or
564 explicit) is resolved.
566 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
571 for identity
in identities:
573 identity[CONF_ID], identity[CONF_NODE_ID], declared, identity.get(CONF_NODE_ID_DERIVED)
579 """Create one identity's command buttons and its "Last 1W Command" diagnostic sensor.
581 Same normalization as create_hub_arming_switch() (hub_entities.py): run a bare {id, name}
582 dict through the platform's own schema so it carries the entity/component defaults register_*() require.
584 Entity names derive from the identity handle and the command ("awning_remote" + "open" ->
585 "Awning Remote Open"), mirroring how the cover's favourite/vent companions derive theirs. The
586 *IDs* follow the documented `<identity_id>_<command>` rule instead, because those are what a
587 `time_based` cover composes against.
589 friendly_identity = identity[CONF_ID].replace(
"_",
" ").title()
591 for command, button_id
in identity[CONF_BUTTON_IDS].items():
592 entity_config = button_component.button_schema(
593 IOHomeOneWayCommandButton,
594 ).extend(cv.COMPONENT_SCHEMA)(
597 CONF_NAME: f
"{friendly_identity} {command.replace('_', ' ').title()}",
600 entity = await button_component.new_button(entity_config)
601 await cg.register_component(entity, entity_config)
602 cg.add(entity.set_parent(var))
603 cg.add(entity.set_controller_id(identity[CONF_ID]))
604 cg.add(entity.set_action(ONEWAY_COMMANDS[command]))
609 sensor_config = text_sensor_component.text_sensor_schema(
610 IOHomeOneWayLastCommandTextSensor,
611 entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
612 ).extend(cv.COMPONENT_SCHEMA)(
614 CONF_ID: identity[CONF_LAST_COMMAND_SENSOR_ID],
615 CONF_NAME: f
"{friendly_identity} Last 1W Command",
618 sensor = await text_sensor_component.new_text_sensor(sensor_config)
619 await cg.register_component(sensor, sensor_config)
620 cg.add(sensor.set_parent(var))
621 cg.add(sensor.set_controller_id(identity[CONF_ID]))
623 if identity[CONF_ENROLLMENT]:
634 enroll_config = button_component.button_schema(
635 IOHomeOneWayEnrollButton,
636 entity_category=ENTITY_CATEGORY_CONFIG,
637 ).extend(cv.COMPONENT_SCHEMA)(
639 CONF_ID: identity[CONF_ENROLL_BUTTON_ID],
640 CONF_NAME: f
"{friendly_identity} Enroll 1W Controller",
643 enroll_entity = await button_component.new_button(enroll_config)
644 await cg.register_component(enroll_entity, enroll_config)
645 cg.add(enroll_entity.set_parent(var))
646 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)
_invert_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)