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