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 (
55 ONEWAY_WIRE_PROFILE_MANUFACTURERS,
57 validate_manufacturer,
62_LOGGER = logging.getLogger(__name__)
69 "open": OneWayButtonAction.OPEN,
70 "close": OneWayButtonAction.CLOSE,
71 "stop": OneWayButtonAction.STOP,
72 "vent": OneWayButtonAction.VENT,
73 "favorite": OneWayButtonAction.FAVORITE,
88 """Reject a repeated class in `enrollment_classes:` -- each just retransmits the same 0x30."""
93 (n
for n, v
in DEVICE_TYPE_OPTIONS.items()
if v == entry), hex(entry)
96 f
"enrollment_classes has '{name}' more than once; each entry adds a burst to the "
97 "enrollment gesture, so a repeat only wastes ~1 second of it"
103ONEWAY_CONTROLLER_SCHEMA = cv.Schema(
105 cv.Required(CONF_ID): cv.string_strict,
108 cv.Optional(CONF_NODE_ID): validate_node_id,
112 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
116 cv.Optional(CONF_MANUFACTURER): validate_manufacturer,
117 cv.Required(CONF_IO_DEVICE_TYPE): validate_device_type,
123 cv.Optional(CONF_INITIAL_SEQUENCE, default=0): cv.int_range(min=0, max=0xFFFF),
127 cv.Optional(CONF_COMMANDS, default=[]): cv.ensure_list(
128 cv.one_of(*ONEWAY_COMMANDS, lower=
True)
132 cv.Optional(CONF_ENROLLMENT, default=
False): cv.boolean,
142 cv.Optional(CONF_ENROLLMENT_WITH_MAC, default=
False): cv.boolean,
151 cv.Optional(CONF_ENROLLMENT_CLASSES): cv.All(
152 cv.ensure_list(cv.All(validate_device_type, cv.int_range(min=1, max=0xFF))),
153 cv.Length(min=1, max=3),
154 _no_duplicate_enrollment_classes,
160 cv.Optional(CONF_EXECUTE_ACEI): cv.All(cv.hex_int, cv.int_range(min=1, max=0xFF)),
164 cv.Optional(CONF_EXECUTE_BROADCAST, default=
"typed"): cv.one_of(
165 "typed",
"all", lower=
True
175 cv.Optional(CONF_LOW_POWER): cv.boolean,
181 """Derive a stable 3-byte 1W source address from the hub's node_id and an identity handle.
183 `node_id` is optional on a `oneway_controllers:` entry because asking a user to invent a
184 3-byte radio address is an unanswerable question — nothing tells them which addresses are
185 safe, and colliding with a real remote in range silently desyncs both transmitters' rolling
186 sequence counters. Deriving one removes the decision while leaving an explicit value
187 available to anyone who needs it.
189 The derivation is done here, at schema time, rather than at runtime on the device, so that a
190 derived address participates in the same collision checks as a configured one and a clash
191 fails the build instead of surfacing as a device that silently ignores commands. It is a
192 pure function of (hub node_id, identity id), so it is stable across builds and reproducible
195 Uses BLAKE2b rather than Python's hash(), which is salted per-process and would produce a
196 different address on every compile.
198 digest = hashlib.blake2b(
199 f
"{hub_node_id}:{identity_id}".encode(), digest_size=3
201 return f
"{digest[0]:02X}{digest[1]:02X}{digest[2]:02X}"
205 """Render a hex string as a C++ brace-initialiser list of bytes."""
207 f
"0x{hex_string[i : i + 2]}" for i
in range(0, len(hex_string), 2)
209 return f
"{{{values}}}"
213 """Render `enrollment_classes:` as a 3-element std::array<DeviceType,3> brace-initialiser.
215 Unset, or fewer than 3, is padded with UNKNOWN (0x00) -- the sentinel effective_enrollment_classes()
216 (oneway_controller.h) reads as "not overridden here" / "skip this slot".
218 padded = (list(identity.get(CONF_ENROLLMENT_CLASSES, [])) + [0, 0, 0])[:3]
220 f
"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
223 return f
"{{{entries}}}"
227 """Map `low_power:` to the C++ `std::optional<OneWayPowerClass>` the identity stores.
229 Tri-state, and the three states are emitted as three distinct values rather than collapsed
230 here: unset -> `{}` (empty optional), `false` -> ALWAYS_ALIVE, `true` -> LOW_POWER.
232 An unset key deliberately does **not** pick a shape at codegen time. Resolving it needs the
233 manufacturer profile, which lives in C++ (`resolve_oneway_wire_profile()`), and splitting that
234 lookup across two languages is how the ACEI and enrollment-class defaults would drift from this
235 one. `effective_power_class()` is the single resolver. See ADR 0041, and ADR 0038 for the
238 if CONF_LOW_POWER
not in identity:
240 name =
"LOW_POWER" if identity[CONF_LOW_POWER]
else "ALWAYS_ALIVE"
241 return f
"esphome::home_io_control::OneWayPowerClass::{name}"
245 """Generate the C++ OneWayControllerIdentity initialiser for one configured identity.
247 Emitted as a designated initialiser so the generated code reads like the YAML that produced
248 it, and so adding a field to the struct cannot silently shift an existing value.
250 derived = identity.get(CONF_NODE_ID_DERIVED,
False)
253 f
'.id = "{identity[CONF_ID]}"',
254 f
".node_id = {_hex_byte_array(identity[CONF_NODE_ID])}",
255 f
".system_key = {_hex_byte_array(identity[CONF_SYSTEM_KEY])}",
256 f
".manufacturer = 0x{identity[CONF_MANUFACTURER]:02X}",
257 f
".io_device_type = static_cast<esphome::home_io_control::DeviceType>"
258 f
"(0x{identity[CONF_IO_DEVICE_TYPE]:02X})",
259 f
".initial_sequence = 0x{identity[CONF_INITIAL_SEQUENCE]:04X}",
260 f
".node_id_derived = {'true' if derived else 'false'}",
261 f
".enrollment_with_mac = {'true' if identity[CONF_ENROLLMENT_WITH_MAC] else 'false'}",
262 f
".execute_acei = 0x{identity.get(CONF_EXECUTE_ACEI, 0):02X}",
263 f
".execute_broadcast_all = "
264 f
"{'true' if identity[CONF_EXECUTE_BROADCAST] == 'all' else 'false'}",
265 f
".enrollment_classes = {_enrollment_classes_initialiser(identity)}",
266 f
".power_class_override = {_power_class_override_expression(identity)}",
271 "home_io_control: oneway_controllers '%s' node_id derived from hub %s -> %s",
274 identity[CONF_NODE_ID],
276 return cg.RawExpression(
277 f
"esphome::home_io_control::OneWayControllerIdentity{{{fields}}}"
282 """Raise if `node_id` is already claimed in `seen_node_ids`; no-op otherwise.
284 Shared between validate_oneway_controllers() (collisions against the hub's own node_id and
285 other oneway_controllers entries) and final_validate_oneway_controller_addresses()
286 (collisions against `linked_remotes:`/`io_device_id:` declared elsewhere in the same YAML),
287 so both raise identically-worded errors regardless of which side of the config the other
290 if node_id
not in seen_node_ids:
292 owner = seen_node_ids[node_id]
294 " (this address was derived; set node_id: explicitly to resolve the clash)"
299 f
"oneway_controllers id '{identity_id}' uses node_id {node_id}, which collides with "
300 f
"{owner}{hint}. Two transmitters sharing an address share a rolling sequence "
301 f
"counter, which silently desyncs both."
306 """Resolve per-identity defaults and reject address/handle collisions at compile time.
308 Runs as a post-validator on the whole hub config because every rule here needs the hub's own
309 `node_id`/`system_key`, which a per-entry validator cannot see.
311 Only checks addresses visible within `home_io_control:` itself (its own `node_id` and every
312 configured `oneway_controllers` entry) — see final_validate_oneway_controller_addresses()
313 below for the matching check against `linked_remotes:`/`io_device_id:` declared elsewhere in
314 the same YAML, which needs the full cross-component config and so cannot run here. Even
315 together the two cannot see a real remote the user has never mentioned to this config at all;
316 see derive_oneway_node_id()'s own docstring for why that residual risk cannot be validated
319 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
323 hub_node_id = config[CONF_NODE_ID]
327 seen_node_ids = {hub_node_id:
"the hub's own node_id"}
329 for identity
in identities:
330 identity_id = identity[CONF_ID]
331 if identity_id
in seen_ids:
333 f
"Duplicate oneway_controllers id '{identity_id}' — each identity needs its own handle"
335 seen_ids.add(identity_id)
337 if CONF_NODE_ID
not in identity:
339 identity[CONF_NODE_ID_DERIVED] =
True
341 node_id = identity[CONF_NODE_ID]
343 seen_node_ids[node_id] = f
"oneway_controllers id '{identity_id}'"
347 if CONF_SYSTEM_KEY
not in identity:
348 identity[CONF_SYSTEM_KEY] = config[CONF_SYSTEM_KEY]
354 if identity[CONF_ENROLLMENT]
and CONF_MANUFACTURER
not in identity:
356 f
"oneway_controllers id '{identity_id}' has enrollment: true but no manufacturer: "
357 "set. Find the value from a key-adoption report for this network (the 'Recover "
358 "1W Controller Key' switch prints it), or from the device's own documentation."
363 manufacturer_explicit = CONF_MANUFACTURER
in identity
364 if CONF_MANUFACTURER
not in identity:
365 identity[CONF_MANUFACTURER] = 0
373 identity_can_transmit = bool(identity[CONF_COMMANDS])
or identity[CONF_ENROLLMENT]
375 manufacturer_explicit
376 and identity[CONF_MANUFACTURER]
not in ONEWAY_WIRE_PROFILE_MANUFACTURERS
377 and identity_can_transmit
380 "home_io_control: oneway_controllers '%s' has manufacturer 0x%02X, which has no "
381 "1W wire profile -- using the Somfy-shaped defaults (ACEI 0x43). Set execute_acei: "
382 "explicitly if that is wrong for your device.",
384 identity[CONF_MANUFACTURER],
391 identity[CONF_ENROLLMENT]
392 and identity[CONF_ENROLLMENT_WITH_MAC]
393 and config[CONF_RADIO_TYPE] ==
"sx1276"
396 "home_io_control: oneway_controllers '%s' has enrollment_with_mac: true on "
397 "radio_type: sx1276, where the MAC-bearing 0x30 has been measured to take about "
398 "twice as long per burst. No VELUX capture carries this MAC either. Consider "
399 "leaving it false unless your hardware specifically needs the MAC-bearing form.",
409 identity[CONF_MANUFACTURER] == MANUFACTURER_OPTIONS[
"velux"]
410 and identity[CONF_ENROLLMENT]
411 and CONF_ENROLLMENT_CLASSES
not in identity
412 and identity[CONF_IO_DEVICE_TYPE]
414 DEVICE_TYPE_OPTIONS[
"screen"],
415 DEVICE_TYPE_OPTIONS[
"blind"],
416 DEVICE_TYPE_OPTIONS[
"venetian_blind"],
420 "home_io_control: oneway_controllers '%s' is a VELUX %s with enrollment: true, but "
421 "enrollment_classes: is unset, so the enrollment 0x30 sweep uses the exterior-shading "
422 "default roller_shutter/awning/dual_shutter and ignores io_device_type. This device may "
423 "enroll on other classes: set enrollment_classes: (a KLI 312 interior blind uses "
424 "[blind, venetian_blind]). To find yours, press Gear on the existing remote and use "
425 "the classes named in its 'rx 1W remote ... (0x2E)' DEBUG log lines -- see 'Finding "
426 "your enrollment classes' in the 1W transmit docs.",
430 for name, value
in DEVICE_TYPE_OPTIONS.items()
431 if value == identity[CONF_IO_DEVICE_TYPE]
438 if CONF_ENROLLMENT_CLASSES
in identity
and (
439 identity[CONF_MANUFACTURER] != MANUFACTURER_OPTIONS[
"velux"]
440 or not identity[CONF_ENROLLMENT]
443 "home_io_control: oneway_controllers '%s' sets enrollment_classes: but it only "
444 "affects the VELUX enrollment gesture (needs manufacturer: velux AND "
445 "enrollment: true) -- it is ignored here.",
453 identity[CONF_BUTTON_IDS] = {
455 f
"{identity_id}_{command}",
457 type=IOHomeOneWayCommandButton,
459 for command
in identity[CONF_COMMANDS]
461 identity[CONF_LAST_COMMAND_SENSOR_ID] = ID(
462 f
"{identity_id}_last_1w_command",
464 type=IOHomeOneWayLastCommandTextSensor,
466 if identity[CONF_ENROLLMENT]:
467 identity[CONF_ENROLL_BUTTON_ID] = ID(
468 f
"{identity_id}_enroll",
470 type=IOHomeOneWayEnrollButton,
479_DEVICE_BOUND_DOMAINS = (
"cover",
"light",
"lock",
"switch")
483 """Map every node ID declared as an `io_device_id:` or a bare `linked_remotes:` entry, across
484 every `home_io_control` entity in `full_config`, to a human-readable owner string.
486 Only entries with `platform: home_io_control` are considered — the domains in
487 _DEVICE_BOUND_DOMAINS are shared with every other component that provides a cover/light/
488 lock/switch platform, and those have nothing to do with this component's address space.
489 `class:` linked_remotes entries name a device *type*, not a node, so they carry no address to
490 collide with and are skipped.
492 CONF_IO_DEVICE_ID/CONF_LINKED_REMOTES are imported locally from platform_common rather than at
493 module level: platform_common imports from the package (`from . import ...`), whose
494 __init__.py imports this module, so a module-level import here would be circular. By the time
495 this function actually runs (final validation, after every used platform module has already
496 been imported), the cycle has already resolved and the import is a plain cache hit.
498 from .platform_common
import CONF_IO_DEVICE_ID, CONF_LINKED_REMOTES
501 for domain
in _DEVICE_BOUND_DOMAINS:
502 for entry
in full_config.get(domain, []):
503 if not isinstance(entry, dict)
or entry.get(CONF_PLATFORM) !=
"home_io_control":
505 owner_name = entry.get(CONF_NAME)
or entry.get(CONF_ID)
or "<unnamed>"
506 device_id = entry.get(CONF_IO_DEVICE_ID)
508 addresses[device_id] = f
"{domain} '{owner_name}' io_device_id"
509 for remote
in entry.get(CONF_LINKED_REMOTES, []):
510 if remote.startswith(
"class:"):
512 addresses[remote] = f
"{domain} '{owner_name}' linked_remotes"
517 """Extend the oneway_controllers address-collision check to addresses declared outside
518 `home_io_control:` — a `linked_remotes:` entry or an `io_device_id:` on some other entity in
521 Runs as FINAL_VALIDATE_SCHEMA rather than inside validate_oneway_controllers() because only
522 final validation has access to the full cross-component config (`fv.full_config`) —
523 `cover:`/`light:`/`lock:`/`switch:` entries are validated independently of
524 `home_io_control:`'s own CONFIG_SCHEMA and are not visible to it. By this point
525 validate_oneway_controllers() has already run, so every identity's `node_id` (derived or
526 explicit) is resolved.
528 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
533 for identity
in identities:
535 identity[CONF_ID], identity[CONF_NODE_ID], declared, identity.get(CONF_NODE_ID_DERIVED)
541 """Create one identity's command buttons and its "Last 1W Command" diagnostic sensor.
543 Same normalization as create_hub_arming_switch() (hub_entities.py): run a bare {id, name}
544 dict through the platform's own schema so it carries the entity/component defaults register_*() require.
546 Entity names derive from the identity handle and the command ("awning_remote" + "open" ->
547 "Awning Remote Open"), mirroring how the cover's favourite/vent companions derive theirs. The
548 *IDs* follow the documented `<identity_id>_<command>` rule instead, because those are what a
549 `time_based` cover composes against.
551 friendly_identity = identity[CONF_ID].replace(
"_",
" ").title()
553 for command, button_id
in identity[CONF_BUTTON_IDS].items():
554 entity_config = button_component.button_schema(
555 IOHomeOneWayCommandButton,
556 ).extend(cv.COMPONENT_SCHEMA)(
559 CONF_NAME: f
"{friendly_identity} {command.replace('_', ' ').title()}",
562 entity = await button_component.new_button(entity_config)
563 await cg.register_component(entity, entity_config)
564 cg.add(entity.set_parent(var))
565 cg.add(entity.set_controller_id(identity[CONF_ID]))
566 cg.add(entity.set_action(ONEWAY_COMMANDS[command]))
571 sensor_config = text_sensor_component.text_sensor_schema(
572 IOHomeOneWayLastCommandTextSensor,
573 entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
574 ).extend(cv.COMPONENT_SCHEMA)(
576 CONF_ID: identity[CONF_LAST_COMMAND_SENSOR_ID],
577 CONF_NAME: f
"{friendly_identity} Last 1W Command",
580 sensor = await text_sensor_component.new_text_sensor(sensor_config)
581 await cg.register_component(sensor, sensor_config)
582 cg.add(sensor.set_parent(var))
583 cg.add(sensor.set_controller_id(identity[CONF_ID]))
585 if identity[CONF_ENROLLMENT]:
596 enroll_config = button_component.button_schema(
597 IOHomeOneWayEnrollButton,
598 entity_category=ENTITY_CATEGORY_CONFIG,
599 ).extend(cv.COMPONENT_SCHEMA)(
601 CONF_ID: identity[CONF_ENROLL_BUTTON_ID],
602 CONF_NAME: f
"{friendly_identity} Enroll 1W Controller",
605 enroll_entity = await button_component.new_button(enroll_config)
606 await cg.register_component(enroll_entity, enroll_config)
607 cg.add(enroll_entity.set_parent(var))
608 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)