Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
oneway_controllers.py
Go to the documentation of this file.
1## @file
2## @brief Schema, validation and code generation for ``home_io_control: oneway_controllers:``.
3## @ingroup hioc_codegen
4##
5## A 1W controller identity is class-addressed (ADR 0027): its own address, key and target
6## class, never an ``io_device_id``. This module derives node IDs, rejects address
7## collisions (including against device-bound platforms, at final-validate time) and
8## generates each identity's command buttons, enrollment button and last-command sensor.
9
10import hashlib
11import logging
12
13import esphome.codegen as cg
14import esphome.config_validation as cv
15import esphome.final_validate as fv
16# Aliased so they cannot be mistaken for this package's own platform modules (see hub_names.py).
17from esphome.components import button as button_component
18from esphome.components import text_sensor as text_sensor_component
19from esphome.const import (
20 CONF_ID,
21 CONF_NAME,
22 CONF_PLATFORM,
23 ENTITY_CATEGORY_CONFIG,
24 ENTITY_CATEGORY_DIAGNOSTIC,
25)
26from esphome.core import ID
27
28from .hub_names import (
29 CONF_BUTTON_IDS,
30 CONF_COMMANDS,
31 CONF_ENROLLMENT,
32 CONF_ENROLLMENT_CLASSES,
33 CONF_ENROLLMENT_WITH_MAC,
34 CONF_ENROLL_BUTTON_ID,
35 CONF_EXECUTE_ACEI,
36 CONF_EXECUTE_BROADCAST,
37 CONF_INITIAL_SEQUENCE,
38 CONF_IO_DEVICE_TYPE,
39 CONF_LAST_COMMAND_SENSOR_ID,
40 CONF_LOW_POWER,
41 CONF_MANUFACTURER,
42 CONF_NODE_ID,
43 CONF_NODE_ID_DERIVED,
44 CONF_ONEWAY_CONTROLLERS,
45 CONF_RADIO_TYPE,
46 CONF_SYSTEM_KEY,
47 IOHomeOneWayCommandButton,
48 IOHomeOneWayEnrollButton,
49 IOHomeOneWayLastCommandTextSensor,
50 OneWayButtonAction,
51)
52from .hub_validators import (
53 DEVICE_TYPE_MAX,
54 DEVICE_TYPE_OPTIONS,
55 MANUFACTURER_OPTIONS,
56 ONEWAY_WIRE_PROFILE_MANUFACTURERS,
57 validate_device_type,
58 validate_manufacturer,
59 validate_node_id,
60 validate_system_key,
61)
62
63_LOGGER = logging.getLogger(__name__)
64
65
66# Command names a user may list, mapped to the C++ enum. OPEN and CLOSE are positions on the
67# wire, not distinct opcodes -- encode_oneway_action() (oneway_controller.h) is where that
68# resolves, so this table stays a plain name->enum mapping.
69ONEWAY_COMMANDS = {
70 "open": OneWayButtonAction.OPEN,
71 "close": OneWayButtonAction.CLOSE,
72 "stop": OneWayButtonAction.STOP,
73 "vent": OneWayButtonAction.VENT,
74 "favorite": OneWayButtonAction.FAVORITE,
75}
76
77
78# A 1W controller identity. 1W is class-addressed — a command goes to a device *class*, never to
79# a node — so there is no `io_device_id` here and none on the entities that will reference this
80# handle. What distinguishes one 1W control surface from another is the controller doing the
81# transmitting: its address, its key, and the class it speaks to. See ADR 0027 and
82# oneway_controller.h.
83#
84# A newly-required key here also needs a matching field in the `oneway_controllers:` block
85# build_oneway_adoption_report() (oneway_key_adoption.cpp) hand-emits for paste-and-reflash (ADR 0018).
86# `make yaml-emitter-sync` (scripts/check-yaml-emitters.py) catches drift between the two
87# statically.
89 """Reject a repeated class in `enrollment_classes:` -- each just retransmits the same 0x30."""
90 seen = set()
91 for entry in value:
92 if entry in seen:
93 name = next(
94 (n for n, v in DEVICE_TYPE_OPTIONS.items() if v == entry), hex(entry)
95 )
96 raise cv.Invalid(
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"
99 )
100 seen.add(entry)
101 return value
102
103
104ONEWAY_CONTROLLER_SCHEMA = cv.Schema(
105 {
106 cv.Required(CONF_ID): cv.string_strict,
107 # Optional and derived when omitted — see derive_oneway_node_id() for why asking the user
108 # for one is an unanswerable question.
109 cv.Optional(CONF_NODE_ID): validate_node_id,
110 # Optional and inherits the hub's key when omitted. cv.sensitive matches the hub's own
111 # system_key handling (the hub schema in __init__.py) so the value is redacted from
112 # ESPHome's config dump; without it a per-identity key would leak into logs verbatim.
113 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
114 # No default here -- see validate_oneway_controllers() for why: it must become required,
115 # not silently 0, whenever enrollment: true actually puts this byte on air. Named or raw
116 # hex, same "known name, else escape hatch" shape as io_device_type below.
117 cv.Optional(CONF_MANUFACTURER): validate_manufacturer,
118 cv.Required(CONF_IO_DEVICE_TYPE): validate_device_type,
119 # Seeds this identity's rolling counter on first use. This is the escape hatch for a
120 # device that has stopped accepting commands because its stored counter ran ahead of
121 # ours: bump this and reflash. Devices accept a forward jump only within a window (~1000
122 # in the one documented receiver implementation), so a value that is too far ahead fails
123 # exactly like one that is too far behind, and just as silently — move it in small steps.
124 cv.Optional(CONF_INITIAL_SEQUENCE, default=0): cv.int_range(min=0, max=0xFFFF),
125 # Which command buttons to generate. Validated against the known set rather than taken
126 # as free text: a typo would otherwise produce a silently missing button, and 1W gives no
127 # runtime signal that would ever reveal one.
128 cv.Optional(CONF_COMMANDS, default=[]): cv.ensure_list(
129 cv.one_of(*ONEWAY_COMMANDS, lower=True)
130 ),
131 # The build flag for this identity's "Enroll 1W Controller" button. See CONF_ENROLLMENT's
132 # own comment for the lifecycle this presence/absence gates.
133 cv.Optional(CONF_ENROLLMENT, default=False): cv.boolean,
134 # Whether the enroll button's 0x30 carries a 6-byte MAC trailer. Real hardware disagrees:
135 # most captures this project holds carry no MAC at all (default here, matching real Somfy
136 # traffic), but a real Izymo has separately been shown to accept the MAC-bearing form too
137 # (the published documentation vector's own shape) -- see create_1w_add_controller()'s
138 # @warning (proto_commands.h). Untested manufacturers (e.g. Velux) may require one shape
139 # or the other; this exists so trying the other one needs a YAML edit, not a code change.
140 # No VELUX capture this project holds carries the MAC trailer, and on sx1276 a MAC-bearing
141 # 0x30 has been measured to take about twice as long per burst -- see
142 # validate_oneway_controllers()'s warning below.
143 cv.Optional(CONF_ENROLLMENT_WITH_MAC, default=False): cv.boolean,
144 # The device classes a VELUX enrollment 0x30 sweep targets. Unset -> the manufacturer
145 # profile default ({roller_shutter, vertical_exterior_awning, dual_roller_shutter} for velux).
146 # Set it to narrow the sweep, e.g. [vertical_exterior_awning], once you know which class
147 # your actuator listens on. Ignored by the somfy gesture (which always uses io_device_type).
148 # Max 3 -- the C++ side is a fixed array.
149 # `unknown` (0x00) is rejected: it is the C++ "not overridden" sentinel
150 # (effective_enrollment_classes()), so [unknown] would silently expand back to the full
151 # three-class sweep -- the opposite of narrowing it. Duplicates are rejected too: each just
152 # retransmits the same frame and costs ~1s of the blocking gesture for nothing.
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,
157 ),
158 # Override the manufacturer-derived ACEI byte for this identity's 1W CMD_EXECUTE frames.
159 # min=1 on purpose: 0 is the C++ "not overridden" sentinel (OneWayControllerIdentity::
160 # execute_acei), so accepting execute_acei: 0 would be a silent no-op instead of an error.
161 # cv.hex_int allows 0x61-style spelling; the range check runs after.
162 cv.Optional(CONF_EXECUTE_ACEI): cv.All(cv.hex_int, cv.int_range(min=1, max=0xFF)),
163 # "all" -> address the all-devices broadcast 00 00 3F for CMD_EXECUTE (what a handheld
164 # cover remote of either vendor does); "typed" (default) -> the per-class address from
165 # io_device_type (current behaviour). Not a vendor axis -- see ADR 0031.
166 cv.Optional(CONF_EXECUTE_BROADCAST, default="typed"): cv.one_of(
167 "typed", "all", lower=True
168 ),
169 # Tri-state, unlike the per-device 2W low_power (ADR 0029): no default here, because unset
170 # and false mean different things for a 1W identity. Unset = every burst keeps the
171 # legacy shape (LONG_PREAMBLE on every copy, CTRL1 0x00) -- byte- and
172 # timing-identical to the hardware-validated Somfy path. false = every copy uses the live
173 # `normal_start_preamble` tuning value instead, CTRL1 0x00 -- the ADR 0029 shape, for a
174 # mains-powered receiver. true = copy 1 gets LONG_PREAMBLE + CTRL1_LOW_POWER, copies 2-4 the
175 # normal preamble -- a wake-up copy for a solar/battery receiver. Applies to every 1W TX of
176 # the identity: commands, positions, both enrollment gestures, un-enrollment. See ADR 0038.
177 cv.Optional(CONF_LOW_POWER): cv.boolean,
178 }
179)
180
181
182def derive_oneway_node_id(hub_node_id, identity_id):
183 """Derive a stable 3-byte 1W source address from the hub's node_id and an identity handle.
184
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.
190
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
195 from the YAML alone.
196
197 Uses BLAKE2b rather than Python's hash(), which is salted per-process and would produce a
198 different address on every compile.
199 """
200 digest = hashlib.blake2b(
201 f"{hub_node_id}:{identity_id}".encode(), digest_size=3
202 ).digest()
203 return f"{digest[0]:02X}{digest[1]:02X}{digest[2]:02X}"
204
205
206def _hex_byte_array(hex_string):
207 """Render a hex string as a C++ brace-initialiser list of bytes."""
208 values = ", ".join(
209 f"0x{hex_string[i : i + 2]}" for i in range(0, len(hex_string), 2)
210 )
211 return f"{{{values}}}"
212
213
215 """Render `enrollment_classes:` as a 3-element std::array<DeviceType,3> brace-initialiser.
216
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".
219 """
220 padded = (list(identity.get(CONF_ENROLLMENT_CLASSES, [])) + [0, 0, 0])[:3]
221 entries = ", ".join(
222 f"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
223 for value in padded
224 )
225 return f"{{{entries}}}"
226
227
229 """Map `low_power:` to the C++ `std::optional<OneWayPowerClass>` the identity stores.
230
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.
233
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
238 shapes themselves.
239 """
240 if CONF_LOW_POWER not in identity:
241 return "{}"
242 name = "LOW_POWER" if identity[CONF_LOW_POWER] else "ALWAYS_ALIVE"
243 return f"esphome::home_io_control::OneWayPowerClass::{name}"
244
245
246def oneway_controller_expression(identity, hub_node_id):
247 """Generate the C++ OneWayControllerIdentity initialiser for one configured identity.
248
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.
251 """
252 derived = identity.get(CONF_NODE_ID_DERIVED, False)
253 fields = ", ".join(
254 [
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)}",
269 ]
270 )
271 if derived:
272 _LOGGER.info(
273 "home_io_control: oneway_controllers '%s' node_id derived from hub %s -> %s",
274 identity[CONF_ID],
275 hub_node_id,
276 identity[CONF_NODE_ID],
277 )
278 return cg.RawExpression(
279 f"esphome::home_io_control::OneWayControllerIdentity{{{fields}}}"
280 )
281
282
283def _reject_node_id_collision(identity_id, node_id, seen_node_ids, derived):
284 """Raise if `node_id` is already claimed in `seen_node_ids`; no-op otherwise.
285
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
290 claimant lives on.
291 """
292 if node_id not in seen_node_ids:
293 return
294 owner = seen_node_ids[node_id]
295 hint = (
296 " (this address was derived; set node_id: explicitly to resolve the clash)"
297 if derived
298 else ""
299 )
300 raise cv.Invalid(
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."
304 )
305
306
308 """Resolve per-identity defaults and reject address/handle collisions at compile time.
309
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.
312
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
319 away.
320 """
321 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
322 if not identities:
323 return config
324
325 hub_node_id = config[CONF_NODE_ID]
326 seen_ids = set()
327 # Maps resolved address -> the human-readable owner, so a collision message can name both
328 # sides rather than just reporting that "an" address is taken.
329 seen_node_ids = {hub_node_id: "the hub's own node_id"}
330
331 for identity in identities:
332 identity_id = identity[CONF_ID]
333 if identity_id in seen_ids:
334 raise cv.Invalid(
335 f"Duplicate oneway_controllers id '{identity_id}' — each identity needs its own handle"
336 )
337 seen_ids.add(identity_id)
338
339 if CONF_NODE_ID not in identity:
340 identity[CONF_NODE_ID] = derive_oneway_node_id(hub_node_id, identity_id)
341 identity[CONF_NODE_ID_DERIVED] = True
342
343 node_id = identity[CONF_NODE_ID]
344 _reject_node_id_collision(identity_id, node_id, seen_node_ids, identity.get(CONF_NODE_ID_DERIVED))
345 seen_node_ids[node_id] = f"oneway_controllers id '{identity_id}'"
346
347 # A per-identity key is optional so that identities on the hub's own network need not
348 # repeat it; an adopted foreign network's key is what makes the override necessary.
349 if CONF_SYSTEM_KEY not in identity:
350 identity[CONF_SYSTEM_KEY] = config[CONF_SYSTEM_KEY]
351
352 # manufacturer becomes required, not silently 0, whenever enrollment: true actually puts
353 # this byte on the air in a CMD_ONEWAY_ADD_CONTROLLER frame -- 1W gives no error a wrong
354 # value would ever surface as, so the mistake has to be caught here instead. Everywhere
355 # else it is genuinely unused today, so defaulting to 0 there is harmless.
356 if identity[CONF_ENROLLMENT] and CONF_MANUFACTURER not in identity:
357 raise cv.Invalid(
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."
361 )
362 # Record whether the user set manufacturer: explicitly, before it is defaulted to 0. The
363 # 1W wire-profile warning below only fires for an *explicit* unprofiled vendor -- an
364 # omitted manufacturer is the common back-compat case and maps silently to Somfy.
365 manufacturer_explicit = CONF_MANUFACTURER in identity
366 if CONF_MANUFACTURER not in identity:
367 identity[CONF_MANUFACTURER] = 0
368
369 # manufacturer: now also drives the 1W CMD_EXECUTE ACEI byte (ADR 0031). We only have a
370 # verified wire profile for Somfy and Velux; any other explicitly-named vendor falls back
371 # to the Somfy-shaped default, which is a guess. Warn -- but not with cv.Invalid, since an
372 # unprofiled vendor is a legal config that still transmits -- and only for an identity that
373 # can actually emit an EXECUTE (has commands: or enrollment:); an inert identity's wire
374 # shape does not matter yet.
375 identity_can_transmit = bool(identity[CONF_COMMANDS]) or identity[CONF_ENROLLMENT]
376 if (
377 manufacturer_explicit
378 and identity[CONF_MANUFACTURER] not in ONEWAY_WIRE_PROFILE_MANUFACTURERS
379 and identity_can_transmit
380 ):
381 _LOGGER.warning(
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.",
385 identity_id,
386 identity[CONF_MANUFACTURER],
387 )
388
389 # enrollment_with_mac: true on sx1276 has been measured to roughly double the 0x30 burst's
390 # airtime, and no VELUX capture this project holds carries the MAC at all -- warn, don't
391 # reject: it is still a legal config, and other radios/vendors are unaffected.
392 if (
393 identity[CONF_ENROLLMENT]
394 and identity[CONF_ENROLLMENT_WITH_MAC]
395 and config[CONF_RADIO_TYPE] == "sx1276"
396 ):
397 _LOGGER.warning(
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.",
402 identity_id,
403 )
404
405 # The velux profile's default 0x30 sweep is the exterior-shading set a KLI 310/313 uses
406 # ({roller_shutter, vertical_exterior_awning, dual_roller_shutter}). An interior-blind remote
407 # (KLI 312) sweeps vertical_interior_blind/interior_venetian_blind instead, so a blind
408 # identity that enrolls with the default
409 # sweep almost certainly misses the actuator. Enrollment ignores io_device_type,
410 # so point the user at enrollment_classes: and at the KLI's own 0x2E lines that name the classes.
411 if (
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]
416 in (
417 DEVICE_TYPE_OPTIONS["vertical_interior_blind"],
418 DEVICE_TYPE_OPTIONS["interior_venetian_blind"],
419 )
420 ):
421 _LOGGER.warning(
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 "
429 "1W transmit docs.",
430 identity_id,
431 next(
432 name
433 for name, value in DEVICE_TYPE_OPTIONS.items()
434 if value == identity[CONF_IO_DEVICE_TYPE]
435 ),
436 )
437
438 # enrollment_classes: only feeds the VELUX enrollment gesture. On a somfy/unprofiled
439 # identity, or one with no enroll button at all, it is silently inert -- flag it, the same
440 # way the blind case above is flagged.
441 if CONF_ENROLLMENT_CLASSES in identity and (
442 identity[CONF_MANUFACTURER] != MANUFACTURER_OPTIONS["velux"]
443 or not identity[CONF_ENROLLMENT]
444 ):
445 _LOGGER.warning(
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.",
449 identity_id,
450 )
451
452 # Entity IDs are declared here, at validation time, not in to_code(): an ID created late
453 # is silently dropped at runtime (ADR 0009). The `<identity_id>_<command>` shape is a
454 # documented contract, not an implementation detail -- users compose `time_based` covers
455 # against these IDs and cannot do that against IDs they can't predict.
456 identity[CONF_BUTTON_IDS] = {
457 command: ID(
458 f"{identity_id}_{command}",
459 is_declaration=True,
460 type=IOHomeOneWayCommandButton,
461 )
462 for command in identity[CONF_COMMANDS]
463 }
464 identity[CONF_LAST_COMMAND_SENSOR_ID] = ID(
465 f"{identity_id}_last_1w_command",
466 is_declaration=True,
467 type=IOHomeOneWayLastCommandTextSensor,
468 )
469 if identity[CONF_ENROLLMENT]:
470 identity[CONF_ENROLL_BUTTON_ID] = ID(
471 f"{identity_id}_enroll",
472 is_declaration=True,
473 type=IOHomeOneWayEnrollButton,
474 )
475
476 return config
477
478
479# cover:/light:/lock:/switch: are the device-bound platforms that can carry an io_device_id: or a
480# linked_remotes: entry (platform_common.py). button:/number:/select:/sensor:/text_sensor: are
481# either not device-bound or, for this component, hub-level only.
482_DEVICE_BOUND_DOMAINS = ("cover", "light", "lock", "switch")
483
484
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.
488
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.
494
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.
500 """
501 from .platform_common import CONF_IO_DEVICE_ID, CONF_LINKED_REMOTES
502
503 addresses = {}
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":
507 continue
508 owner_name = entry.get(CONF_NAME) or entry.get(CONF_ID) or "<unnamed>"
509 device_id = entry.get(CONF_IO_DEVICE_ID)
510 if 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:"):
514 continue
515 addresses[remote] = f"{domain} '{owner_name}' linked_remotes"
516 return addresses
517
518
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
522 this same YAML.
523
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.
530 """
531 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
532 if not identities:
533 return config
534
535 declared = _collect_declared_device_addresses(fv.full_config.get())
536 for identity in identities:
538 identity[CONF_ID], identity[CONF_NODE_ID], declared, identity.get(CONF_NODE_ID_DERIVED)
539 )
540 return config
541
542
543async def create_oneway_controller_entities(identity, var):
544 """Create one identity's command buttons and its "Last 1W Command" diagnostic sensor.
545
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.
548
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.
553 """
554 friendly_identity = identity[CONF_ID].replace("_", " ").title()
555
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)(
560 {
561 CONF_ID: button_id,
562 CONF_NAME: f"{friendly_identity} {command.replace('_', ' ').title()}",
563 }
564 )
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]))
570
571 # Always created, even with no buttons: an identity driven only by the
572 # `oneway_set_position` action still needs somewhere to show what it sent, and with no reply
573 # frame this sensor is the only place that can ever appear.
574 sensor_config = text_sensor_component.text_sensor_schema(
575 IOHomeOneWayLastCommandTextSensor,
576 entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
577 ).extend(cv.COMPONENT_SCHEMA)(
578 {
579 CONF_ID: identity[CONF_LAST_COMMAND_SENSOR_ID],
580 CONF_NAME: f"{friendly_identity} Last 1W Command",
581 }
582 )
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]))
587
588 if identity[CONF_ENROLLMENT]:
589 # entity_category: config, not a switch behind an arming flag: the receiver's own 2s PROG
590 # hold is the real interlock -- a hub cannot enroll into a device nobody has walked up to
591 # (ADR 0026). ADR 0021's bootloader precedent doesn't apply here (that ADR is for an
592 # irreversible write; un-enrollment exists here as a rollback path).
593 #
594 # No "(May Replace Existing Remotes)" caveat: enrolling a new identity onto a device is
595 # additive, not destructive -- an existing remote keeps working alongside a newly enrolled
596 # hub. Un-enrollment (`0x39`, the `oneway_remove_controller` action) has not been confirmed
597 # to work on real hardware yet -- see that action's own doxygen (management_actions.h) --
598 # so "reversible" is the design intent, not yet a demonstrated fact.
599 enroll_config = button_component.button_schema(
600 IOHomeOneWayEnrollButton,
601 entity_category=ENTITY_CATEGORY_CONFIG,
602 ).extend(cv.COMPONENT_SCHEMA)(
603 {
604 CONF_ID: identity[CONF_ENROLL_BUTTON_ID],
605 CONF_NAME: f"{friendly_identity} Enroll 1W Controller",
606 }
607 )
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]))
derive_oneway_node_id(hub_node_id, identity_id)
oneway_controller_expression(identity, hub_node_id)
_reject_node_id_collision(identity_id, node_id, seen_node_ids, derived)