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_INVERT_POSITION,
39 CONF_IO_DEVICE_TYPE,
40 CONF_LAST_COMMAND_SENSOR_ID,
41 CONF_LOW_POWER,
42 CONF_MANUFACTURER,
43 CONF_NODE_ID,
44 CONF_NODE_ID_DERIVED,
45 CONF_ONEWAY_CONTROLLERS,
46 CONF_RADIO_TYPE,
47 CONF_SYSTEM_KEY,
48 IOHomeOneWayCommandButton,
49 IOHomeOneWayEnrollButton,
50 IOHomeOneWayLastCommandTextSensor,
51 OneWayButtonAction,
52)
53from .hub_validators import (
54 DEVICE_TYPE_MAX,
55 DEVICE_TYPE_OPTIONS,
56 FEATURE_INVERTED,
57 MANUFACTURER_OPTIONS,
58 ONEWAY_WIRE_PROFILE_MANUFACTURERS,
59 device_features,
60 validate_device_type,
61 validate_manufacturer,
62 validate_node_id,
63 validate_system_key,
64)
65
66_LOGGER = logging.getLogger(__name__)
67
68
69# Command names a user may list, mapped to the C++ enum. OPEN and CLOSE are positions on the
70# wire, not distinct opcodes -- encode_oneway_action() (oneway_controller.h) is where that
71# resolves, so this table stays a plain name->enum mapping.
72ONEWAY_COMMANDS = {
73 "open": OneWayButtonAction.OPEN,
74 "close": OneWayButtonAction.CLOSE,
75 "stop": OneWayButtonAction.STOP,
76 "vent": OneWayButtonAction.VENT,
77 "favorite": OneWayButtonAction.FAVORITE,
78}
79
80
81# A 1W controller identity. 1W is class-addressed — a command goes to a device *class*, never to
82# a node — so there is no `io_device_id` here and none on the entities that will reference this
83# handle. What distinguishes one 1W control surface from another is the controller doing the
84# transmitting: its address, its key, and the class it speaks to. See ADR 0027 and
85# oneway_controller.h.
86#
87# A newly-required key here also needs a matching field in the `oneway_controllers:` block
88# build_oneway_adoption_report() (oneway_key_adoption.cpp) hand-emits for paste-and-reflash (ADR 0018).
89# `make yaml-emitter-sync` (scripts/check-yaml-emitters.py) catches drift between the two
90# statically.
92 """Reject a repeated class in `enrollment_classes:` -- each just retransmits the same 0x30."""
93 seen = set()
94 for entry in value:
95 if entry in seen:
96 name = next(
97 (n for n, v in DEVICE_TYPE_OPTIONS.items() if v == entry), hex(entry)
98 )
99 raise cv.Invalid(
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"
102 )
103 seen.add(entry)
104 return value
105
106
107ONEWAY_CONTROLLER_SCHEMA = cv.Schema(
108 {
109 cv.Required(CONF_ID): cv.string_strict,
110 # Optional and derived when omitted — see derive_oneway_node_id() for why asking the user
111 # for one is an unanswerable question.
112 cv.Optional(CONF_NODE_ID): validate_node_id,
113 # Optional and inherits the hub's key when omitted. cv.sensitive matches the hub's own
114 # system_key handling (the hub schema in __init__.py) so the value is redacted from
115 # ESPHome's config dump; without it a per-identity key would leak into logs verbatim.
116 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
117 # No default here -- see validate_oneway_controllers() for why: it must become required,
118 # not silently 0, whenever enrollment: true actually puts this byte on air. Named or raw
119 # hex, same "known name, else escape hatch" shape as io_device_type below.
120 cv.Optional(CONF_MANUFACTURER): validate_manufacturer,
121 cv.Required(CONF_IO_DEVICE_TYPE): validate_device_type,
122 # Seeds this identity's rolling counter on first use. This is the escape hatch for a
123 # device that has stopped accepting commands because its stored counter ran ahead of
124 # ours: bump this and reflash. Devices accept a forward jump only within a window (~1000
125 # in the one documented receiver implementation), so a value that is too far ahead fails
126 # exactly like one that is too far behind, and just as silently — move it in small steps.
127 cv.Optional(CONF_INITIAL_SEQUENCE, default=0): cv.int_range(min=0, max=0xFFFF),
128 # Which command buttons to generate. Validated against the known set rather than taken
129 # as free text: a typo would otherwise produce a silently missing button, and 1W gives no
130 # runtime signal that would ever reveal one.
131 cv.Optional(CONF_COMMANDS, default=[]): cv.ensure_list(
132 cv.one_of(*ONEWAY_COMMANDS, lower=True)
133 ),
134 # The build flag for this identity's "Enroll 1W Controller" button. See CONF_ENROLLMENT's
135 # own comment for the lifecycle this presence/absence gates.
136 cv.Optional(CONF_ENROLLMENT, default=False): cv.boolean,
137 # Whether the enroll button's 0x30 carries a 6-byte MAC trailer. Real hardware disagrees:
138 # most captures this project holds carry no MAC at all (default here, matching real Somfy
139 # traffic), but a real Izymo has separately been shown to accept the MAC-bearing form too
140 # (the published documentation vector's own shape) -- see create_1w_add_controller()'s
141 # @warning (proto_commands.h). Untested manufacturers (e.g. Velux) may require one shape
142 # or the other; this exists so trying the other one needs a YAML edit, not a code change.
143 # No VELUX capture this project holds carries the MAC trailer, and on sx1276 a MAC-bearing
144 # 0x30 has been measured to take about twice as long per burst -- see
145 # validate_oneway_controllers()'s warning below.
146 cv.Optional(CONF_ENROLLMENT_WITH_MAC, default=False): cv.boolean,
147 # The device classes a VELUX enrollment 0x30 sweep targets. Unset -> the manufacturer
148 # profile default ({roller_shutter, vertical_exterior_awning, dual_roller_shutter} for velux).
149 # Set it to narrow the sweep, e.g. [vertical_exterior_awning], once you know which class
150 # your actuator listens on. Ignored by the somfy gesture (which always uses io_device_type).
151 # Max 3 -- the C++ side is a fixed array.
152 # `unknown` (0x00) is rejected: it is the C++ "not overridden" sentinel
153 # (effective_enrollment_classes()), so [unknown] would silently expand back to the full
154 # three-class sweep -- the opposite of narrowing it. Duplicates are rejected too: each just
155 # retransmits the same frame and costs ~1s of the blocking gesture for nothing.
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,
160 ),
161 # Override the manufacturer-derived ACEI byte for this identity's 1W CMD_EXECUTE frames.
162 # min=1 on purpose: 0 is the C++ "not overridden" sentinel (OneWayControllerIdentity::
163 # execute_acei), so accepting execute_acei: 0 would be a silent no-op instead of an error.
164 # cv.hex_int allows 0x61-style spelling; the range check runs after.
165 cv.Optional(CONF_EXECUTE_ACEI): cv.All(cv.hex_int, cv.int_range(min=1, max=0xFF)),
166 # "all" -> address the all-devices broadcast 00 00 3F for CMD_EXECUTE (what a handheld
167 # cover remote of either vendor does); "typed" (default) -> the per-class address from
168 # io_device_type (current behaviour). Not a vendor axis -- see ADR 0031.
169 cv.Optional(CONF_EXECUTE_BROADCAST, default="typed"): cv.one_of(
170 "typed", "all", lower=True
171 ),
172 # Tri-state, unlike the per-device 2W low_power (ADR 0029): no default here, because unset
173 # and false mean different things for a 1W identity. Unset = every burst keeps the
174 # legacy shape (LONG_PREAMBLE on every copy, CTRL1 0x00) -- byte- and
175 # timing-identical to the hardware-validated Somfy path. false = every copy uses the live
176 # `normal_start_preamble` tuning value instead, CTRL1 0x00 -- the ADR 0029 shape, for a
177 # mains-powered receiver. true = copy 1 gets LONG_PREAMBLE + CTRL1_LOW_POWER, copies 2-4 the
178 # normal preamble -- a wake-up copy for a solar/battery receiver. Applies to every 1W TX of
179 # the identity: commands, positions, both enrollment gestures, un-enrollment. See ADR 0038.
180 cv.Optional(CONF_LOW_POWER): cv.boolean,
181 # Tri-state like low_power: unset = the device kind decides (an inverted kind such as a
182 # horizontal awning mirrors OPEN/CLOSE/position on the wire, as the 2W cover does);
183 # true/false force it. Resolved in C++ by effective_inverted().
184 cv.Optional(CONF_INVERT_POSITION): cv.boolean,
185 }
186)
187
188
189def derive_oneway_node_id(hub_node_id, identity_id):
190 """Derive a stable 3-byte 1W source address from the hub's node_id and an identity handle.
191
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.
197
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
202 from the YAML alone.
203
204 Uses BLAKE2b rather than Python's hash(), which is salted per-process and would produce a
205 different address on every compile.
206 """
207 digest = hashlib.blake2b(
208 f"{hub_node_id}:{identity_id}".encode(), digest_size=3
209 ).digest()
210 return f"{digest[0]:02X}{digest[1]:02X}{digest[2]:02X}"
211
212
213def _hex_byte_array(hex_string):
214 """Render a hex string as a C++ brace-initialiser list of bytes."""
215 values = ", ".join(
216 f"0x{hex_string[i : i + 2]}" for i in range(0, len(hex_string), 2)
217 )
218 return f"{{{values}}}"
219
220
222 """Render `enrollment_classes:` as a 3-element std::array<DeviceType,3> brace-initialiser.
223
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".
226 """
227 padded = (list(identity.get(CONF_ENROLLMENT_CLASSES, [])) + [0, 0, 0])[:3]
228 entries = ", ".join(
229 f"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
230 for value in padded
231 )
232 return f"{{{entries}}}"
233
234
236 """Map `low_power:` to the C++ `std::optional<OneWayPowerClass>` the identity stores.
237
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.
240
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
245 shapes themselves.
246 """
247 if CONF_LOW_POWER not in identity:
248 return "{}"
249 name = "LOW_POWER" if identity[CONF_LOW_POWER] else "ALWAYS_ALIVE"
250 return f"esphome::home_io_control::OneWayPowerClass::{name}"
251
252
254 """Map `invert_position:` to the C++ `std::optional<bool>` the identity stores.
255
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.
258 """
259 if CONF_INVERT_POSITION not in identity:
260 return "{}"
261 return "true" if identity[CONF_INVERT_POSITION] else "false"
262
263
264def oneway_controller_expression(identity, hub_node_id):
265 """Generate the C++ OneWayControllerIdentity initialiser for one configured identity.
266
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.
269 """
270 derived = identity.get(CONF_NODE_ID_DERIVED, False)
271 fields = ", ".join(
272 [
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)}",
288 ]
289 )
290 if derived:
291 _LOGGER.info(
292 "home_io_control: oneway_controllers '%s' node_id derived from hub %s -> %s",
293 identity[CONF_ID],
294 hub_node_id,
295 identity[CONF_NODE_ID],
296 )
297 return cg.RawExpression(
298 f"esphome::home_io_control::OneWayControllerIdentity{{{fields}}}"
299 )
300
301
302def _reject_node_id_collision(identity_id, node_id, seen_node_ids, derived):
303 """Raise if `node_id` is already claimed in `seen_node_ids`; no-op otherwise.
304
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
309 claimant lives on.
310 """
311 if node_id not in seen_node_ids:
312 return
313 owner = seen_node_ids[node_id]
314 hint = (
315 " (this address was derived; set node_id: explicitly to resolve the clash)"
316 if derived
317 else ""
318 )
319 raise cv.Invalid(
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."
323 )
324
325
327 """Resolve per-identity defaults and reject address/handle collisions at compile time.
328
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.
331
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
338 away.
339 """
340 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
341 if not identities:
342 return config
343
344 hub_node_id = config[CONF_NODE_ID]
345 seen_ids = set()
346 # Maps resolved address -> the human-readable owner, so a collision message can name both
347 # sides rather than just reporting that "an" address is taken.
348 seen_node_ids = {hub_node_id: "the hub's own node_id"}
349
350 for identity in identities:
351 identity_id = identity[CONF_ID]
352 if identity_id in seen_ids:
353 raise cv.Invalid(
354 f"Duplicate oneway_controllers id '{identity_id}' — each identity needs its own handle"
355 )
356 seen_ids.add(identity_id)
357
358 if CONF_NODE_ID not in identity:
359 identity[CONF_NODE_ID] = derive_oneway_node_id(hub_node_id, identity_id)
360 identity[CONF_NODE_ID_DERIVED] = True
361
362 node_id = identity[CONF_NODE_ID]
363 _reject_node_id_collision(identity_id, node_id, seen_node_ids, identity.get(CONF_NODE_ID_DERIVED))
364 seen_node_ids[node_id] = f"oneway_controllers id '{identity_id}'"
365
366 # A per-identity key is optional so that identities on the hub's own network need not
367 # repeat it; an adopted foreign network's key is what makes the override necessary.
368 if CONF_SYSTEM_KEY not in identity:
369 identity[CONF_SYSTEM_KEY] = config[CONF_SYSTEM_KEY]
370
371 # manufacturer becomes required, not silently 0, whenever enrollment: true actually puts
372 # this byte on the air in a CMD_ONEWAY_ADD_CONTROLLER frame -- 1W gives no error a wrong
373 # value would ever surface as, so the mistake has to be caught here instead. Everywhere
374 # else it is genuinely unused today, so defaulting to 0 there is harmless.
375 if identity[CONF_ENROLLMENT] and CONF_MANUFACTURER not in identity:
376 raise cv.Invalid(
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."
380 )
381 # Record whether the user set manufacturer: explicitly, before it is defaulted to 0. The
382 # 1W wire-profile warning below only fires for an *explicit* unprofiled vendor -- an
383 # omitted manufacturer is the common back-compat case and maps silently to Somfy.
384 manufacturer_explicit = CONF_MANUFACTURER in identity
385 if CONF_MANUFACTURER not in identity:
386 identity[CONF_MANUFACTURER] = 0
387
388 # manufacturer: now also drives the 1W CMD_EXECUTE ACEI byte (ADR 0031). We only have a
389 # verified wire profile for Somfy and Velux; any other explicitly-named vendor falls back
390 # to the Somfy-shaped default, which is a guess. Warn -- but not with cv.Invalid, since an
391 # unprofiled vendor is a legal config that still transmits -- and only for an identity that
392 # can actually emit an EXECUTE (has commands: or enrollment:); an inert identity's wire
393 # shape does not matter yet.
394 identity_can_transmit = bool(identity[CONF_COMMANDS]) or identity[CONF_ENROLLMENT]
395 if (
396 manufacturer_explicit
397 and identity[CONF_MANUFACTURER] not in ONEWAY_WIRE_PROFILE_MANUFACTURERS
398 and identity_can_transmit
399 ):
400 _LOGGER.warning(
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.",
404 identity_id,
405 identity[CONF_MANUFACTURER],
406 )
407
408 # enrollment_with_mac: true on sx1276 has been measured to roughly double the 0x30 burst's
409 # airtime, and no VELUX capture this project holds carries the MAC at all -- warn, don't
410 # reject: it is still a legal config, and other radios/vendors are unaffected.
411 if (
412 identity[CONF_ENROLLMENT]
413 and identity[CONF_ENROLLMENT_WITH_MAC]
414 and config[CONF_RADIO_TYPE] == "sx1276"
415 ):
416 _LOGGER.warning(
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.",
421 identity_id,
422 )
423
424 # The velux profile's default 0x30 sweep is the exterior-shading set a KLI 310/313 uses
425 # ({roller_shutter, vertical_exterior_awning, dual_roller_shutter}). An interior-blind remote
426 # (KLI 312) sweeps vertical_interior_blind/interior_venetian_blind instead, so a blind
427 # identity that enrolls with the default
428 # sweep almost certainly misses the actuator. Enrollment ignores io_device_type,
429 # so point the user at enrollment_classes: and at the KLI's own 0x2E lines that name the classes.
430 if (
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]
435 in (
436 DEVICE_TYPE_OPTIONS["vertical_interior_blind"],
437 DEVICE_TYPE_OPTIONS["interior_venetian_blind"],
438 )
439 ):
440 _LOGGER.warning(
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 "
448 "1W transmit docs.",
449 identity_id,
450 next(
451 name
452 for name, value in DEVICE_TYPE_OPTIONS.items()
453 if value == identity[CONF_IO_DEVICE_TYPE]
454 ),
455 )
456
457 # An inverted kind mirrors open/close on the wire. A broadcast-to-all frame also reaches
458 # receivers of other kinds, so the mirror applies to them too -- legal, but worth a flag.
459 if (
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
464 ):
465 _LOGGER.warning(
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.",
470 identity_id,
471 )
472
473 # enrollment_classes: only feeds the VELUX enrollment gesture. On a somfy/unprofiled
474 # identity, or one with no enroll button at all, it is silently inert -- flag it, the same
475 # way the blind case above is flagged.
476 if CONF_ENROLLMENT_CLASSES in identity and (
477 identity[CONF_MANUFACTURER] != MANUFACTURER_OPTIONS["velux"]
478 or not identity[CONF_ENROLLMENT]
479 ):
480 _LOGGER.warning(
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.",
484 identity_id,
485 )
486
487 # Entity IDs are declared here, at validation time, not in to_code(): an ID created late
488 # is silently dropped at runtime (ADR 0009). The `<identity_id>_<command>` shape is a
489 # documented contract, not an implementation detail -- users compose `time_based` covers
490 # against these IDs and cannot do that against IDs they can't predict.
491 identity[CONF_BUTTON_IDS] = {
492 command: ID(
493 f"{identity_id}_{command}",
494 is_declaration=True,
495 type=IOHomeOneWayCommandButton,
496 )
497 for command in identity[CONF_COMMANDS]
498 }
499 identity[CONF_LAST_COMMAND_SENSOR_ID] = ID(
500 f"{identity_id}_last_1w_command",
501 is_declaration=True,
502 type=IOHomeOneWayLastCommandTextSensor,
503 )
504 if identity[CONF_ENROLLMENT]:
505 identity[CONF_ENROLL_BUTTON_ID] = ID(
506 f"{identity_id}_enroll",
507 is_declaration=True,
508 type=IOHomeOneWayEnrollButton,
509 )
510
511 return config
512
513
514# cover:/light:/lock:/switch: are the device-bound platforms that can carry an io_device_id: or a
515# linked_remotes: entry (platform_common.py). button:/number:/select:/sensor:/text_sensor: are
516# either not device-bound or, for this component, hub-level only.
517_DEVICE_BOUND_DOMAINS = ("cover", "light", "lock", "switch")
518
519
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.
523
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.
529
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.
535 """
536 from .platform_common import CONF_IO_DEVICE_ID, CONF_LINKED_REMOTES
537
538 addresses = {}
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":
542 continue
543 owner_name = entry.get(CONF_NAME) or entry.get(CONF_ID) or "<unnamed>"
544 device_id = entry.get(CONF_IO_DEVICE_ID)
545 if 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:"):
549 continue
550 addresses[remote] = f"{domain} '{owner_name}' linked_remotes"
551 return addresses
552
553
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
557 this same YAML.
558
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.
565 """
566 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
567 if not identities:
568 return config
569
570 declared = _collect_declared_device_addresses(fv.full_config.get())
571 for identity in identities:
573 identity[CONF_ID], identity[CONF_NODE_ID], declared, identity.get(CONF_NODE_ID_DERIVED)
574 )
575 return config
576
577
578async def create_oneway_controller_entities(identity, var):
579 """Create one identity's command buttons and its "Last 1W Command" diagnostic sensor.
580
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.
583
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.
588 """
589 friendly_identity = identity[CONF_ID].replace("_", " ").title()
590
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)(
595 {
596 CONF_ID: button_id,
597 CONF_NAME: f"{friendly_identity} {command.replace('_', ' ').title()}",
598 }
599 )
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]))
605
606 # Always created, even with no buttons: an identity driven only by the
607 # `oneway_set_position` action still needs somewhere to show what it sent, and with no reply
608 # frame this sensor is the only place that can ever appear.
609 sensor_config = text_sensor_component.text_sensor_schema(
610 IOHomeOneWayLastCommandTextSensor,
611 entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
612 ).extend(cv.COMPONENT_SCHEMA)(
613 {
614 CONF_ID: identity[CONF_LAST_COMMAND_SENSOR_ID],
615 CONF_NAME: f"{friendly_identity} Last 1W Command",
616 }
617 )
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]))
622
623 if identity[CONF_ENROLLMENT]:
624 # entity_category: config, not a switch behind an arming flag: the receiver's own 2s PROG
625 # hold is the real interlock -- a hub cannot enroll into a device nobody has walked up to
626 # (ADR 0026). ADR 0021's bootloader precedent doesn't apply here (that ADR is for an
627 # irreversible write; un-enrollment exists here as a rollback path).
628 #
629 # No "(May Replace Existing Remotes)" caveat: enrolling a new identity onto a device is
630 # additive, not destructive -- an existing remote keeps working alongside a newly enrolled
631 # hub. Un-enrollment (`0x39`, the `oneway_remove_controller` action) reverses it only while
632 # the receiver is in its programming window, confirmed on one receiver so far -- see that
633 # action's own doxygen (management_actions.h).
634 enroll_config = button_component.button_schema(
635 IOHomeOneWayEnrollButton,
636 entity_category=ENTITY_CATEGORY_CONFIG,
637 ).extend(cv.COMPONENT_SCHEMA)(
638 {
639 CONF_ID: identity[CONF_ENROLL_BUTTON_ID],
640 CONF_NAME: f"{friendly_identity} Enroll 1W Controller",
641 }
642 )
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]))
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)