Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
__init__.py
Go to the documentation of this file.
1## @file
2## @brief ESPHome hub schema and code generation for Home IO Control.
3## @ingroup hioc_codegen
4##
5## Defines the top-level ``home_io_control:`` YAML schema, shared validators, and the
6## generated C++ hub component wiring used by the platform modules.
7
8import hashlib
9import logging
10import urllib.error
11import urllib.request
12
13import esphome.codegen as cg
14import esphome.config_validation as cv
15import esphome.final_validate as fv
16from esphome import pins
17from esphome.components import spi
18# Aliased: this package has its own switch.py/button.py submodules, so a plain `from
19# esphome.components import switch` here would bind the name `switch` in this __init__.py's
20# namespace — which, because __init__.py IS the esphome.components.home_io_control package
21# object, is the exact same slot ESPHome's component loader later overwrites when it imports our
22# own switch.py/button.py platform files as `esphome.components.home_io_control.switch` /
23# `...button`. Without the alias, whichever import happens to run last silently wins, and
24# to_code() (called even later, when tasks are flushed) can end up resolving `switch`/`button` to
25# our own platform files instead of the real ESPHome components.
26from esphome.components import button as button_component
27from esphome.components import switch as switch_component
28from esphome.components import text_sensor as text_sensor_component
29from esphome.const import (
30 CONF_DEVICE_ID,
31 CONF_ID,
32 CONF_INVERTED,
33 CONF_NAME,
34 CONF_PLATFORM,
35 CONF_REF,
36 CONF_SOURCE,
37 ENTITY_CATEGORY_CONFIG,
38 ENTITY_CATEGORY_DIAGNOSTIC,
39)
40from esphome.core import CORE, ID
41from esphome.helpers import write_file_if_changed
42
43from . import lr1121_firmware
44from . import tuning as tuning_module
45
46_LOGGER = logging.getLogger(__name__)
47
48DEPENDENCIES = ["api", "spi"]
49AUTO_LOAD = ["button", "climate", "cover", "light", "lock", "number", "select", "sensor", "switch", "text_sensor"]
50MULTI_CONF = False
51
52CONF_HOME_IO_CONTROL_ID = "home_io_control_id"
53CONF_RST_PIN = "rst_pin"
54CONF_DIO0_PIN = "dio0_pin"
55CONF_DIO4_PIN = "dio4_pin"
56CONF_DIO1_PIN = "dio1_pin"
57CONF_BUSY_PIN = "busy_pin"
58CONF_NODE_ID = "node_id"
59CONF_SYSTEM_KEY = "system_key"
60CONF_TX_POWER = "tx_power"
61CONF_PA_PIN = "pa_pin"
62CONF_RADIO_TYPE = "radio_type"
63CONF_FEM_EN_PIN = "fem_en_pin"
64CONF_VFEM_PIN = "vfem_pin"
65CONF_FEM_PA_PIN = "fem_pa_pin"
66CONF_TCXO_VOLTAGE = "tcxo_voltage"
67CONF_EXPOSED_SENDERS = "exposed_senders"
68CONF_ACCEPT_FOREIGN_PAIRING = "accept_foreign_pairing"
69CONF_RECOVER_ONEWAY_KEY = "recover_oneway_key"
70CONF_SCAN_PAIRED_DEVICES_BUTTON = "scan_paired_devices_button"
71CONF_ONEWAY_CONTROLLERS = "oneway_controllers"
72# Internal marker recording that an identity's node_id was derived rather than configured, so
73# validation errors and the boot log can say which it was.
74CONF_NODE_ID_DERIVED = "_node_id_derived"
75CONF_MANUFACTURER = "manufacturer"
76# Same YAML key as platform_common.py's CONF_DEVICE_TYPE, spelled here rather than imported:
77# platform_common imports from this module, so the dependency cannot run the other way.
78CONF_IO_DEVICE_TYPE = "io_device_type"
79CONF_INITIAL_SEQUENCE = "initial_sequence"
80CONF_COMMANDS = "commands"
81# Per-identity 1W wire overrides (ADR 0031). execute_acei: overrides the manufacturer-derived
82# ACEI byte for CMD_EXECUTE; execute_broadcast: all|typed picks the all-devices (00 00 3F) vs
83# typed-class destination — a handheld-remote-vs-class-bound axis, not a vendor axis.
84CONF_EXECUTE_ACEI = "execute_acei"
85CONF_EXECUTE_BROADCAST = "execute_broadcast"
86# The build flag for this identity's "Enroll 1W Controller" button. Presence/absence is the whole
87# gate -- adding or removing this line and reflashing is the enrollment feature's entire
88# lifecycle, same shape as accept_foreign_pairing/recover_oneway_key.
89CONF_ENROLLMENT = "enrollment"
90# Whether the "Enroll 1W Controller" button's 0x30 carries a trailing MAC. Only meaningful with
91# enrollment: true -- see ONEWAY_CONTROLLER_SCHEMA's own comment and create_1w_add_controller()'s
92# @warning (proto_commands.h) for why real hardware disagrees on this byte.
93CONF_ENROLLMENT_WITH_MAC = "enrollment_with_mac"
94# Override for the device classes a VELUX enrollment 0x30 sweep targets. Unset -> the manufacturer
95# profile's list (velux: {roller_shutter, awning, dual_shutter}). Ignored by the somfy gesture.
96# See resolve_oneway_wire_profile() / effective_enrollment_classes() (oneway_controller.h), ADR 0032.
97CONF_ENROLLMENT_CLASSES = "enrollment_classes"
98# Injected at schema time, never user-supplied: the generated buttons' IDs and the identity's
99# diagnostic sensor ID (ADR 0009).
100CONF_BUTTON_IDS = "button_ids"
101CONF_LAST_COMMAND_SENSOR_ID = "last_command_sensor_id"
102# Only present when enrollment: true (ADR 0009 -- an ID created late in to_code() is silently
103# dropped at runtime).
104CONF_ENROLL_BUTTON_ID = "_enroll_button_id"
105CONF_DIAGNOSTIC_PROBES = "diagnostic_probes"
106CONF_LR1121_FIRMWARE_UPDATE = "lr1121_firmware_update"
107CONF_LR1121_BOOTLOADER = "bootloader"
108CONF_CHECKSUM_MD5 = "checksum_md5"
109CONF_TARGET_VERSION = "target_version"
110MIN_STATUS_POLL_INTERVAL_MS = 500
111
112# Internal config key for the "Accept Foreign Pairing" companion switch ID (injected by
113# post-validator, same pattern as tuning.py's companion entity IDs — ESPHome 2026.x sizes the
114# runtime component vector from IDs known at the end of schema validation, so a companion
115# entity created only in to_code() would silently drop; see tuning.py::_inject_tuning_companion_ids
116# for the fuller rationale).
117CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID = "_accept_foreign_pairing_switch_id"
118# Internal config key for the "Recover 1W Controller Key" companion switch ID (injected by
119# post-validator; same rationale as CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID above).
120CONF_RECOVER_ONEWAY_KEY_SWITCH_ID = "_recover_oneway_key_switch_id"
121# Internal config key for the "Flash LR1121 Radio Firmware" companion button ID (injected by
122# post-validator; same rationale as CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID above).
123CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID = "_lr1121_firmware_update_button_id"
124# Internal config key for the "Scan Paired Devices" companion button ID (injected by
125# post-validator; same rationale as CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID above).
126CONF_SCAN_PAIRED_DEVICES_BUTTON_ID = "_scan_paired_devices_button_id"
127# Internal config key for the "Allow LR1121 Bootloader Rewrite (Irreversible)" companion switch ID
128# (injected by post-validator; same rationale as CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID above --
129# only present when lr1121_firmware_update.bootloader: is configured).
130CONF_LR1121_BOOTLOADER_SWITCH_ID = "_lr1121_bootloader_switch_id"
131
132home_io_control_ns = cg.esphome_ns.namespace("home_io_control")
133IOHomeControlComponent = home_io_control_ns.class_(
134 "IOHomeControlComponent", cg.Component, spi.SPIDevice
135)
136# Hub-level "Recover System Key" switch (key extraction, key_extraction_responder.cpp /
137# platform_hub_controls.h). Deliberately NOT exposed via a `switch:` platform entry: earlier
138# revisions dispatched on the presence/absence of `io_device_id` within switch.py, which meant an
139# ordinary device-bound switch missing `io_device_id` by mistake would silently become this
140# security-sensitive switch instead of failing validation. Gating it behind this boolean (created
141# dynamically, like the `tuning:` UI controls) makes that class of mistake structurally
142# impossible: there is no shared schema for the two to be confused under.
143IOHomeAcceptForeignPairingSwitch = home_io_control_ns.class_(
144 "IOHomeAcceptForeignPairingSwitch", switch_component.Switch, cg.Component
145)
146# Hub-level "Recover 1W Controller Key" switch (key adoption, oneway_key_adoption.cpp /
147# platform_hub_controls.h). Same dynamically-created, hub-bound shape and rationale as the switch
148# above. Independent of accept_foreign_pairing — the two arm different listeners (2W pairing
149# responder vs. 1W add-controller broadcast).
150IOHomeRecoverOneWayKeySwitch = home_io_control_ns.class_(
151 "IOHomeRecoverOneWayKeySwitch", switch_component.Switch, cg.Component
152)
153# Hub-level "Flash LR1121 Radio Firmware" button (lr1121_firmware_update_controller.cpp /
154# platform_lr1121_controls.h). Same "created dynamically from a home_io_control: sub-block, not a
155# device-bound platform entry" shape as the switch above — there is no `io_device_id` to bind this
156# to, it targets the hub's own radio.
157IOHomeLr1121FirmwareUpdateButton = home_io_control_ns.class_(
158 "IOHomeLr1121FirmwareUpdateButton", button_component.Button, cg.Component
159)
160# Hub-level "Scan Paired Devices" button (management_actions.cpp / platform_hub_controls.h). An
161# additional trigger for the already-registered `scan_paired_devices` native API action, which is
162# unchanged. Same "created dynamically from the home_io_control: block" shape as the entities
163# above -- there is no `io_device_id` to bind a roll-call to.
164IOHomeScanPairedDevicesButton = home_io_control_ns.class_(
165 "IOHomeScanPairedDevicesButton", button_component.Button, cg.Component
166)
167# Generated 1W command buttons and their per-identity diagnostic sensor
168# (platform_oneway_entities.h). Created from the `oneway_controllers:` block, never a `button:`
169# entry, for the same reason as the switch above.
170IOHomeOneWayCommandButton = home_io_control_ns.class_(
171 "IOHomeOneWayCommandButton", button_component.Button, cg.Component
172)
173IOHomeOneWayLastCommandTextSensor = home_io_control_ns.class_(
174 "IOHomeOneWayLastCommandTextSensor", text_sensor_component.TextSensor, cg.Component
175)
176# Generated 1W enrollment button (platform_oneway_entities.h), one per identity with
177# `enrollment: true`. Same "created from the hub block, never a `button:` entry" reasoning as
178# IOHomeOneWayCommandButton above -- see that class's comment.
179IOHomeOneWayEnrollButton = home_io_control_ns.class_(
180 "IOHomeOneWayEnrollButton", button_component.Button, cg.Component
181)
182OneWayButtonAction = home_io_control_ns.enum("OneWayButtonAction", is_class=True)
183# Command names a user may list, mapped to the C++ enum. OPEN and CLOSE are positions on the
184# wire, not distinct opcodes -- encode_oneway_action() (oneway_controller.h) is where that
185# resolves, so this table stays a plain name->enum mapping.
186ONEWAY_COMMANDS = {
187 "open": OneWayButtonAction.OPEN,
188 "close": OneWayButtonAction.CLOSE,
189 "stop": OneWayButtonAction.STOP,
190 "vent": OneWayButtonAction.VENT,
191 "favorite": OneWayButtonAction.FAVORITE,
192}
193# Hub-level "Allow LR1121 Bootloader Rewrite (Irreversible)" arming switch
194# (lr1121_firmware_update_controller.cpp / platform_lr1121_controls.h). Same dynamically-created,
195# hub-bound shape as the two entities above; created only when lr1121_firmware_update.bootloader:
196# is configured (see _create_lr1121_bootloader_update()).
197IOHomeLr1121BootloaderRewriteSwitch = home_io_control_ns.class_(
198 "IOHomeLr1121BootloaderRewriteSwitch", switch_component.Switch, cg.Component
199)
200
201
202def _inject_hub_entity_id(config, *, flag_key, id_key, suffix, cls):
203 """Shared body for the hub-level entities gated by a bare boolean flag in the
204 `home_io_control:` block (accept_foreign_pairing, recover_oneway_key,
205 scan_paired_devices_button): declare the entity's ID during validation, under the
206 `{hub_id}_{suffix}` name, only when its flag is set. See companion_id_base() in
207 platform_common.py for why this must happen at validation time rather than in to_code().
208 """
209 if not config[flag_key]:
210 return config
211 parent_id = config[CONF_ID]
212 base = parent_id.id if parent_id.id else "home_io_control"
213 config[id_key] = ID(f"{base}_{suffix}", is_declaration=True, type=cls)
214 return config
215
216
219 config,
220 flag_key=CONF_ACCEPT_FOREIGN_PAIRING,
221 id_key=CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID,
222 suffix="accept_foreign_pairing_switch",
223 cls=IOHomeAcceptForeignPairingSwitch,
224 )
225
226
229 config,
230 flag_key=CONF_RECOVER_ONEWAY_KEY,
231 id_key=CONF_RECOVER_ONEWAY_KEY_SWITCH_ID,
232 suffix="recover_oneway_key_switch",
233 cls=IOHomeRecoverOneWayKeySwitch,
234 )
235
236
239 config,
240 flag_key=CONF_SCAN_PAIRED_DEVICES_BUTTON,
241 id_key=CONF_SCAN_PAIRED_DEVICES_BUTTON_ID,
242 suffix="scan_paired_devices_button",
243 cls=IOHomeScanPairedDevicesButton,
244 )
245
246
247def validate_lr1121_firmware_source(value, *, expect_loader=False):
248 """Validate the lr1121_firmware_update `source:` shorthand at schema time.
249
250 Checks the shape (github://owner/repo/path[@ref]) and the image class (transceiver vs.
251 loader vs. modem, by filename -- see lr1121_firmware.validate_image_class()). The network
252 fetch and MD5/image-content verification happen later, in to_code(), where a failure is
253 still a build-time error but one that needs the network anyway.
254 @param expect_loader True for the bootloader sub-block's `source:` (must be a loader image),
255 False for the ordinary transceiver `source:` (must not be one).
256 """
257 value = cv.string_strict(value)
258 try:
259 _, _, path, _ = lr1121_firmware.parse_github_source(value)
260 lr1121_firmware.validate_image_class(path, expect_loader=expect_loader)
262 raise cv.Invalid(str(err)) from err
263 return value
264
265
267 """Validate checksum_md5 as exactly 32 hex characters (MD5)."""
268 value = cv.string_strict(value).lower()
269 if len(value) != 32:
270 raise cv.Invalid("checksum_md5 must be exactly 32 hex characters (MD5)")
271 try:
272 int(value, 16)
273 except ValueError as err:
274 raise cv.Invalid("checksum_md5 must be valid hexadecimal") from err
275 return value
276
277
278# The bootloader sub-block's `source:` must BE a loader image (expect_loader=True) -- the
279# symmetric guard to the outer schema's default expect_loader=False (C8 in the bootloader update
280# ADR 0021): a transceiver image in this slot would erase and overwrite the wrong thing
281# at stage 1a.
282LR1121_BOOTLOADER_SCHEMA = cv.Schema(
283 {
284 cv.Required(CONF_SOURCE): lambda value: validate_lr1121_firmware_source(value, expect_loader=True),
285 cv.Optional(CONF_REF): cv.string_strict,
286 cv.Optional(CONF_CHECKSUM_MD5): validate_checksum_md5,
287 }
288)
289
290LR1121_FIRMWARE_UPDATE_SCHEMA = cv.Schema(
291 {
292 cv.Required(CONF_SOURCE): validate_lr1121_firmware_source,
293 cv.Optional(CONF_REF): cv.string_strict,
294 cv.Optional(CONF_CHECKSUM_MD5): validate_checksum_md5,
295 # target_version exists solely as an escape hatch for a mirrored/renamed image whose
296 # filename carries no version — NOT as a compatibility declaration. There is deliberately
297 # no `requires_bootloader:` key: a user-declared compatibility claim is the wrong shape
298 # for a safety check, since the build can derive it from the filename instead.
299 cv.Optional(CONF_TARGET_VERSION): cv.hex_int,
300 # Presence is the build flag for the bootloader-rewrite feature, exactly as the outer
301 # block's presence already is for the transceiver-update feature -- see ADR 0021.
302 cv.Optional(CONF_LR1121_BOOTLOADER): LR1121_BOOTLOADER_SCHEMA,
303 }
304)
305
306
307def _validate_lr1121_bootloader_block(config):
308 """Implement the build-time compatibility rule for the bootloader: sub-block (ADR 0021).
309
310 Classifies the *outer* source:'s target against LR1121_KNOWN_BOOTLOADER_REQUIREMENTS without
311 any network access (both source: filenames are already schema-validated shapes at this point,
312 so parsing them again here is free). Deliberately three-way, like the runtime compatibility
313 rule: an unrecognised target warns rather than errors, so the feature doesn't rot on Semtech's
314 next release (see lr1121_firmware.classify_bootloader_upgrade_class()'s doc comment).
315 """
316 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
317 if CONF_LR1121_BOOTLOADER not in fw_config:
318 return config
319
320 target_fw = lr1121_firmware.resolve_target_version(fw_config[CONF_SOURCE], fw_config.get(CONF_TARGET_VERSION))
321 upgrade_class = lr1121_firmware.classify_bootloader_upgrade_class(target_fw)
322 if upgrade_class == "hard_error":
323 raise cv.Invalid(
324 f"lr1121_firmware_update.bootloader: is configured, but source: targets firmware 0x{target_fw:04X}, "
325 "which is known to require bootloader 0x2100 -- after the bootloader rewrite this image would be "
326 "unflashable, so this configuration would arm a trap. Point source: at a firmware version requiring "
327 "bootloader 0x2101 (e.g. 0x0104), or remove the bootloader: block."
328 )
329 if upgrade_class == "unknown":
330 _LOGGER.warning(
331 "lr1121_firmware_update.bootloader: is configured, but source: targets an unrecognized firmware "
332 "version (0x%04X); the bootloader-rewrite path will be inert at runtime until this build's "
333 "compatibility table is extended for it (see lr1121_firmware_decisions.h)",
334 target_fw,
335 )
336
337 parent_id = config[CONF_ID]
338 base = parent_id.id if parent_id.id else "home_io_control"
339 config[CONF_LR1121_BOOTLOADER_SWITCH_ID] = ID(
340 f"{base}_lr1121_bootloader_switch",
341 is_declaration=True,
342 type=IOHomeLr1121BootloaderRewriteSwitch,
343 )
344 return config
345
346
347def _validate_lr1121_firmware_update(config):
348 """Gate + inject the button ID for the optional lr1121_firmware_update: block.
349
350 Only runs when the block is present. Rejects configurations that can't reach the LR1121
351 bootloader at all (wrong radio_type, missing busy_pin) or that would silently invert the
352 bootloader-entry level (busy_pin inverted: true — bootloader entry drives BUSY to a physical
353 LOW; see radio_lr1121_firmware_updater.h). Also injects the flash button's companion ID at
354 validation time — see CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID's comment above for why that
355 can't wait until to_code(). The bootloader:-specific checks (C3-C5, and the companion arming
356 switch's ID) live in _validate_lr1121_bootloader_block() above, called at the end of this
357 function so config[CONF_ID] and the reachability checks are already settled.
358 """
359 if CONF_LR1121_FIRMWARE_UPDATE not in config:
360 return config
361 try:
362 lr1121_firmware.validate_bootloader_reachability(
363 radio_type=config[CONF_RADIO_TYPE],
364 has_busy_pin=CONF_BUSY_PIN in config,
365 busy_pin_inverted=config.get(CONF_BUSY_PIN, {}).get(CONF_INVERTED, False),
366 )
368 raise cv.Invalid(str(err)) from err
369
370 parent_id = config[CONF_ID]
371 base = parent_id.id if parent_id.id else "home_io_control"
372 config[CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID] = ID(
373 f"{base}_lr1121_firmware_update_button",
374 is_declaration=True,
375 type=IOHomeLr1121FirmwareUpdateButton,
376 )
377 return _validate_lr1121_bootloader_block(config)
378
379
380PA_PIN_OPTIONS = {
381 "BOOST": 0x80,
382 "RFO": 0x00,
383}
384
385RADIO_TYPE_OPTIONS = {
386 "sx1276": "sx1276",
387 "sx1262": "sx1262",
388 "lr1121": "lr1121",
389}
390
391TCXO_VOLTAGE_OPTIONS = {
392 "1_6V": 0x01,
393 "1_7V": 0x02,
394 "1_8V": 0x03,
395 "2_2V": 0x04,
396 "2_4V": 0x05,
397 "2_7V": 0x06,
398 "3_0V": 0x07,
399 "3_3V": 0x08,
400}
401
402DEVICE_TYPE_OPTIONS = {
403 "unknown": 0x00,
404 "venetian_blind": 0x01,
405 "roller_shutter": 0x02,
406 "awning": 0x03,
407 "window_opener": 0x04,
408 "garage_opener": 0x05,
409 "light": 0x06,
410 "gate_opener": 0x07,
411 "rolling_door_opener": 0x08,
412 "lock": 0x09,
413 "blind": 0x0A,
414 "screen": 0x0B,
415 "dual_shutter": 0x0D,
416 "heating_temperature_interface": 0x0E,
417 "on_off_switch": 0x0F,
418 "horizontal_awning": 0x10,
419 "external_venetian_blind": 0x11,
420 "louvre_blind": 0x12,
421 "curtain_track": 0x13,
422 "intrusion_alarm": 0x17,
423 "swinging_shutter": 0x18,
424}
425
426
427# Mirrors proto_constants.h's MANUFACTURER_* constants (IO-Homecontrol alliance-assigned IDs) --
428# lowercased versions of those constant names, so a name typo'd here is easy to spot against the
429# C++ source. Not every possible byte has a name (MANUFACTURER_ID_MAX=12); an identity whose real
430# manufacturer isn't in this table still works via the raw-integer escape hatch every caller of
431# _resolve_named_or_raw_token() below shares.
432MANUFACTURER_OPTIONS = {
433 "velux": 0x01,
434 "somfy": 0x02,
435 "honeywell": 0x03,
436 "hormann": 0x04,
437 "assa_abloy": 0x05,
438 "niko": 0x06,
439 "window_master": 0x07,
440 "renson": 0x08,
441 "ciat": 0x09,
442 "secuyou": 0x0A,
443 "overkiz": 0x0B,
444 "atlantic_group": 0x0C,
445}
446
447# Manufacturer bytes for which oneway_controller.h's resolve_oneway_wire_profile() has a real 1W
448# wire profile. Keep this set in sync with that C++ switch — two values, and there is no automated
449# check (scripts/check-yaml-emitters.py compares key names, not table contents).
450_ONEWAY_WIRE_PROFILE_MANUFACTURERS = {
451 MANUFACTURER_OPTIONS["somfy"],
452 MANUFACTURER_OPTIONS["velux"],
453}
454
455
456def _resolve_named_or_raw_token(token, options, max_value=0xFF):
457 """Resolve a lowercase, stripped token (a name from `options`, or a raw int/hex string) to
458 an integer 0-`max_value`.
459
460 Shared "named value, else raw integer" acceptance rule: validate_device_type()/
461 validate_linked_remote_entry() (DEVICE_TYPE_OPTIONS) and validate_manufacturer()
462 (MANUFACTURER_OPTIONS) are the same shape of small, protocol-defined enum with an escape
463 hatch for values this project hasn't named yet, so the lookup lives here once.
464 @raises ValueError if token is neither a known name nor a parseable integer.
465 @raises cv.Invalid if token parses as an integer but is out of range.
466 """
467 if token in options:
468 return options[token]
469 return cv.int_range(min=0, max=max_value)(int(token, 0))
470
471
472def _resolve_device_type_token(token):
473 """Resolve a lowercase, stripped device-type token (name or raw int/hex string) to 0-255.
474
475 Single source of truth for the "named value from DEVICE_TYPE_OPTIONS, else raw integer"
476 acceptance rule shared by validate_device_type() (io_device_type) and
477 validate_linked_remote_entry() (the class:<device_type> linked-remotes form) — both accept
478 the exact same set of device-type spellings, so the lookup lives here once.
479 @raises ValueError if token is neither a known name nor a parseable integer.
480 @raises cv.Invalid if token parses as an integer but is out of range 0-255.
481 """
482 return _resolve_named_or_raw_token(token, DEVICE_TYPE_OPTIONS)
483
484
485def validate_device_type(value):
486 """Validate io_device_type as a named string or integer 0-255."""
487 if isinstance(value, int):
488 return cv.int_range(min=0, max=0xFF)(value)
489
490 if isinstance(value, str):
491 normalized = cv.string_strict(value).strip().lower()
492 try:
493 return _resolve_device_type_token(normalized)
494 except ValueError as err:
495 raise cv.Invalid(
496 "Device type must be a known name or an integer in the range 0..255 (for example 0x11)"
497 ) from err
498
499 raise cv.Invalid(
500 "Device type must be a known name or an integer in the range 0..255"
501 )
502
503
504def validate_manufacturer(value):
505 """Validate manufacturer as a named string (MANUFACTURER_OPTIONS) or integer 0-255.
506
507 Mirrors validate_device_type() exactly (same "name, else raw integer" shape via
508 _resolve_named_or_raw_token()) — a manufacturer ID is the same kind of small,
509 protocol-defined enum, it just has no linked_remotes-style second caller.
510 """
511 if isinstance(value, int):
512 return cv.int_range(min=0, max=0xFF)(value)
513
514 if isinstance(value, str):
515 normalized = cv.string_strict(value).strip().lower()
516 try:
517 return _resolve_named_or_raw_token(normalized, MANUFACTURER_OPTIONS)
518 except ValueError as err:
519 raise cv.Invalid(
520 "manufacturer must be a known name or an integer in the range 0..255 (for example 0x02)"
521 ) from err
522
523 raise cv.Invalid("manufacturer must be a known name or an integer in the range 0..255")
524
525
526def device_type_expression(value):
527 """Generate a C++ static_cast expression for a validated device type."""
528 return cg.RawExpression(
529 f"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
530 )
531
532
533def validate_node_id(value):
534 """Validate node_id as exactly 6 hex characters (3 bytes)."""
535 value = cv.string_strict(value).upper()
536 if len(value) != 6:
537 raise cv.Invalid("Node ID must be exactly 6 hex characters (3 bytes)")
538 try:
539 int(value, 16)
540 except ValueError:
541 raise cv.Invalid("Node ID must be valid hexadecimal")
542 return value
543
544
545def validate_system_key(value):
546 """Validate system_key as exactly 32 hex characters (16 bytes)."""
547 value = cv.string_strict(value).upper()
548 if len(value) != 32:
549 raise cv.Invalid("System key must be exactly 32 hex characters (16 bytes)")
550 try:
551 int(value, 16)
552 except ValueError:
553 raise cv.Invalid("System key must be valid hexadecimal")
554 return value
555
556
557# A 1W controller identity. 1W is class-addressed — a command goes to a device *class*, never to
558# a node — so there is no `io_device_id` here and none on the entities that will reference this
559# handle. What distinguishes one 1W control surface from another is the controller doing the
560# transmitting: its address, its key, and the class it speaks to. See ADR 0027 and
561# oneway_controller.h.
562#
563# A newly-required key here also needs a matching field in the `oneway_controllers:` block
564# build_oneway_adoption_report() (hub_internal.h) hand-emits for paste-and-reflash (ADR 0018).
565# `make yaml-emitter-sync` (scripts/check-yaml-emitters.py) catches drift between the two
566# statically.
567def _no_duplicate_enrollment_classes(value):
568 """Reject a repeated class in `enrollment_classes:` -- each just retransmits the same 0x30."""
569 seen = set()
570 for entry in value:
571 if entry in seen:
572 name = next(
573 (n for n, v in DEVICE_TYPE_OPTIONS.items() if v == entry), hex(entry)
574 )
575 raise cv.Invalid(
576 f"enrollment_classes has '{name}' more than once; each entry adds a burst to the "
577 "enrollment gesture, so a repeat only wastes ~1 second of it"
578 )
579 seen.add(entry)
580 return value
581
582
583ONEWAY_CONTROLLER_SCHEMA = cv.Schema(
584 {
585 cv.Required(CONF_ID): cv.string_strict,
586 # Optional and derived when omitted — see derive_oneway_node_id() for why asking the user
587 # for one is an unanswerable question.
588 cv.Optional(CONF_NODE_ID): validate_node_id,
589 # Optional and inherits the hub's key when omitted. cv.sensitive matches the hub's own
590 # system_key handling (see the main schema below) so the value is redacted from ESPHome's
591 # config dump; without it a per-identity key would leak into logs verbatim.
592 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
593 # No default here -- see _validate_oneway_controllers() for why: it must become required,
594 # not silently 0, whenever enrollment: true actually puts this byte on air. Named or raw
595 # hex, same "known name, else escape hatch" shape as io_device_type below.
596 cv.Optional(CONF_MANUFACTURER): validate_manufacturer,
597 cv.Required(CONF_IO_DEVICE_TYPE): validate_device_type,
598 # Seeds this identity's rolling counter on first use. This is the escape hatch for a
599 # device that has stopped accepting commands because its stored counter ran ahead of
600 # ours: bump this and reflash. Devices accept a forward jump only within a window (~1000
601 # in the one documented receiver implementation), so a value that is too far ahead fails
602 # exactly like one that is too far behind, and just as silently — move it in small steps.
603 cv.Optional(CONF_INITIAL_SEQUENCE, default=0): cv.int_range(min=0, max=0xFFFF),
604 # Which command buttons to generate. Validated against the known set rather than taken
605 # as free text: a typo would otherwise produce a silently missing button, and 1W gives no
606 # runtime signal that would ever reveal one.
607 cv.Optional(CONF_COMMANDS, default=[]): cv.ensure_list(
608 cv.one_of(*ONEWAY_COMMANDS, lower=True)
609 ),
610 # The build flag for this identity's "Enroll 1W Controller" button. See CONF_ENROLLMENT's
611 # own comment for the lifecycle this presence/absence gates.
612 cv.Optional(CONF_ENROLLMENT, default=False): cv.boolean,
613 # Whether the enroll button's 0x30 carries a 6-byte MAC trailer. Real hardware disagrees:
614 # most captures this project holds carry no MAC at all (default here, matching real Somfy
615 # traffic), but a real Izymo has separately been shown to accept the MAC-bearing form too
616 # (the published documentation vector's own shape) -- see create_1w_add_controller()'s
617 # @warning (proto_commands.h). Untested manufacturers (e.g. Velux) may require one shape
618 # or the other; this exists so trying the other one needs a YAML edit, not a code change.
619 cv.Optional(CONF_ENROLLMENT_WITH_MAC, default=False): cv.boolean,
620 # The device classes a VELUX enrollment 0x30 sweep targets. Unset -> the manufacturer
621 # profile default ({roller_shutter, awning, dual_shutter} for velux). Set it to narrow the
622 # sweep, e.g. [awning], once you know which class your actuator listens on. Ignored by the
623 # somfy gesture (which always uses io_device_type). Max 3 -- the C++ side is a fixed array.
624 # `unknown` (0x00) is rejected: it is the C++ "not overridden" sentinel
625 # (effective_enrollment_classes()), so [unknown] would silently expand back to the full
626 # three-class sweep -- the opposite of narrowing it. Duplicates are rejected too: each just
627 # retransmits the same frame and costs ~1s of the blocking gesture for nothing.
628 cv.Optional(CONF_ENROLLMENT_CLASSES): cv.All(
629 cv.ensure_list(cv.All(validate_device_type, cv.int_range(min=1, max=0xFF))),
630 cv.Length(min=1, max=3),
631 _no_duplicate_enrollment_classes,
632 ),
633 # Override the manufacturer-derived ACEI byte for this identity's 1W CMD_EXECUTE frames.
634 # min=1 on purpose: 0 is the C++ "not overridden" sentinel (OneWayControllerIdentity::
635 # execute_acei), so accepting execute_acei: 0 would be a silent no-op instead of an error.
636 # cv.hex_int allows 0x61-style spelling; the range check runs after.
637 cv.Optional(CONF_EXECUTE_ACEI): cv.All(cv.hex_int, cv.int_range(min=1, max=0xFF)),
638 # "all" -> address the all-devices broadcast 00 00 3F for CMD_EXECUTE (what a handheld
639 # cover remote of either vendor does); "typed" (default) -> the per-class address from
640 # io_device_type (current behaviour). Not a vendor axis -- see ADR 0031.
641 cv.Optional(CONF_EXECUTE_BROADCAST, default="typed"): cv.one_of(
642 "typed", "all", lower=True
643 ),
644 }
645)
646
647
648def derive_oneway_node_id(hub_node_id, identity_id):
649 """Derive a stable 3-byte 1W source address from the hub's node_id and an identity handle.
650
651 `node_id` is optional on a `oneway_controllers:` entry because asking a user to invent a
652 3-byte radio address is an unanswerable question — nothing tells them which addresses are
653 safe, and colliding with a real remote in range silently desyncs both transmitters' rolling
654 sequence counters. Deriving one removes the decision while leaving an explicit value
655 available to anyone who needs it.
656
657 The derivation is done here, at schema time, rather than at runtime on the device, so that a
658 derived address participates in the same collision checks as a configured one and a clash
659 fails the build instead of surfacing as a device that silently ignores commands. It is a
660 pure function of (hub node_id, identity id), so it is stable across builds and reproducible
661 from the YAML alone.
662
663 Uses BLAKE2b rather than Python's hash(), which is salted per-process and would produce a
664 different address on every compile.
665 """
666 digest = hashlib.blake2b(
667 f"{hub_node_id}:{identity_id}".encode(), digest_size=3
668 ).digest()
669 return f"{digest[0]:02X}{digest[1]:02X}{digest[2]:02X}"
670
671
672def _hex_byte_array(hex_string):
673 """Render a hex string as a C++ brace-initialiser list of bytes."""
674 values = ", ".join(
675 f"0x{hex_string[i : i + 2]}" for i in range(0, len(hex_string), 2)
676 )
677 return f"{{{values}}}"
678
679
680def _enrollment_classes_initialiser(identity):
681 """Render `enrollment_classes:` as a 3-element std::array<DeviceType,3> brace-initialiser.
682
683 Unset, or fewer than 3, is padded with UNKNOWN (0x00) -- the sentinel effective_enrollment_classes()
684 (oneway_controller.h) reads as "not overridden here" / "skip this slot".
685 """
686 padded = (list(identity.get(CONF_ENROLLMENT_CLASSES, [])) + [0, 0, 0])[:3]
687 entries = ", ".join(
688 f"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
689 for value in padded
690 )
691 return f"{{{entries}}}"
692
693
694def oneway_controller_expression(identity, hub_node_id):
695 """Generate the C++ OneWayControllerIdentity initialiser for one configured identity.
696
697 Emitted as a designated initialiser so the generated code reads like the YAML that produced
698 it, and so adding a field to the struct cannot silently shift an existing value.
699 """
700 derived = identity.get(CONF_NODE_ID_DERIVED, False)
701 fields = ", ".join(
702 [
703 f'.id = "{identity[CONF_ID]}"',
704 f".node_id = {_hex_byte_array(identity[CONF_NODE_ID])}",
705 f".system_key = {_hex_byte_array(identity[CONF_SYSTEM_KEY])}",
706 f".manufacturer = 0x{identity[CONF_MANUFACTURER]:02X}",
707 f".io_device_type = static_cast<esphome::home_io_control::DeviceType>"
708 f"(0x{identity[CONF_IO_DEVICE_TYPE]:02X})",
709 f".initial_sequence = 0x{identity[CONF_INITIAL_SEQUENCE]:04X}",
710 f".node_id_derived = {'true' if derived else 'false'}",
711 f".enrollment_with_mac = {'true' if identity[CONF_ENROLLMENT_WITH_MAC] else 'false'}",
712 f".execute_acei = 0x{identity.get(CONF_EXECUTE_ACEI, 0):02X}",
713 f".execute_broadcast_all = "
714 f"{'true' if identity[CONF_EXECUTE_BROADCAST] == 'all' else 'false'}",
715 f".enrollment_classes = {_enrollment_classes_initialiser(identity)}",
716 ]
717 )
718 if derived:
719 _LOGGER.info(
720 "home_io_control: oneway_controllers '%s' node_id derived from hub %s -> %s",
721 identity[CONF_ID],
722 hub_node_id,
723 identity[CONF_NODE_ID],
724 )
725 return cg.RawExpression(
726 f"esphome::home_io_control::OneWayControllerIdentity{{{fields}}}"
727 )
728
729
730def _reject_node_id_collision(identity_id, node_id, seen_node_ids, derived):
731 """Raise if `node_id` is already claimed in `seen_node_ids`; no-op otherwise.
732
733 Shared between _validate_oneway_controllers() (collisions against the hub's own node_id and
734 other oneway_controllers entries) and _final_validate_oneway_controller_addresses()
735 (collisions against `linked_remotes:`/`io_device_id:` declared elsewhere in the same YAML),
736 so both raise identically-worded errors regardless of which side of the config the other
737 claimant lives on.
738 """
739 if node_id not in seen_node_ids:
740 return
741 owner = seen_node_ids[node_id]
742 hint = (
743 " (this address was derived; set node_id: explicitly to resolve the clash)"
744 if derived
745 else ""
746 )
747 raise cv.Invalid(
748 f"oneway_controllers id '{identity_id}' uses node_id {node_id}, which collides with "
749 f"{owner}{hint}. Two transmitters sharing an address share a rolling sequence "
750 f"counter, which silently desyncs both."
751 )
752
753
754def _validate_oneway_controllers(config):
755 """Resolve per-identity defaults and reject address/handle collisions at compile time.
756
757 Runs as a post-validator on the whole hub config because every rule here needs the hub's own
758 `node_id`/`system_key`, which a per-entry validator cannot see.
759
760 Only checks addresses visible within `home_io_control:` itself (its own `node_id` and every
761 configured `oneway_controllers` entry) — see _final_validate_oneway_controller_addresses()
762 below for the matching check against `linked_remotes:`/`io_device_id:` declared elsewhere in
763 the same YAML, which needs the full cross-component config and so cannot run here. Even
764 together the two cannot see a real remote the user has never mentioned to this config at all;
765 see derive_oneway_node_id()'s own docstring for why that residual risk cannot be validated
766 away.
767 """
768 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
769 if not identities:
770 return config
771
772 hub_node_id = config[CONF_NODE_ID]
773 seen_ids = set()
774 # Maps resolved address -> the human-readable owner, so a collision message can name both
775 # sides rather than just reporting that "an" address is taken.
776 seen_node_ids = {hub_node_id: "the hub's own node_id"}
777
778 for identity in identities:
779 identity_id = identity[CONF_ID]
780 if identity_id in seen_ids:
781 raise cv.Invalid(
782 f"Duplicate oneway_controllers id '{identity_id}' — each identity needs its own handle"
783 )
784 seen_ids.add(identity_id)
785
786 if CONF_NODE_ID not in identity:
787 identity[CONF_NODE_ID] = derive_oneway_node_id(hub_node_id, identity_id)
788 identity[CONF_NODE_ID_DERIVED] = True
789
790 node_id = identity[CONF_NODE_ID]
791 _reject_node_id_collision(identity_id, node_id, seen_node_ids, identity.get(CONF_NODE_ID_DERIVED))
792 seen_node_ids[node_id] = f"oneway_controllers id '{identity_id}'"
793
794 # A per-identity key is optional so that identities on the hub's own network need not
795 # repeat it; an adopted foreign network's key is what makes the override necessary.
796 if CONF_SYSTEM_KEY not in identity:
797 identity[CONF_SYSTEM_KEY] = config[CONF_SYSTEM_KEY]
798
799 # manufacturer becomes required, not silently 0, whenever enrollment: true actually puts
800 # this byte on the air in a CMD_ONEWAY_ADD_CONTROLLER frame -- 1W gives no error a wrong
801 # value would ever surface as, so the mistake has to be caught here instead. Everywhere
802 # else it is genuinely unused today, so defaulting to 0 there is harmless.
803 if identity[CONF_ENROLLMENT] and CONF_MANUFACTURER not in identity:
804 raise cv.Invalid(
805 f"oneway_controllers id '{identity_id}' has enrollment: true but no manufacturer: "
806 "set. Find the value from a key-adoption report for this network (the 'Recover "
807 "1W Controller Key' switch prints it), or from the device's own documentation."
808 )
809 # Record whether the user set manufacturer: explicitly, before it is defaulted to 0. The
810 # 1W wire-profile warning below only fires for an *explicit* unprofiled vendor -- an
811 # omitted manufacturer is the common back-compat case and maps silently to Somfy.
812 manufacturer_explicit = CONF_MANUFACTURER in identity
813 if CONF_MANUFACTURER not in identity:
814 identity[CONF_MANUFACTURER] = 0
815
816 # manufacturer: now also drives the 1W CMD_EXECUTE ACEI byte (ADR 0031). We only have a
817 # verified wire profile for Somfy and Velux; any other explicitly-named vendor falls back
818 # to the Somfy-shaped default, which is a guess. Warn -- but not with cv.Invalid, since an
819 # unprofiled vendor is a legal config that still transmits -- and only for an identity that
820 # can actually emit an EXECUTE (has commands: or enrollment:); an inert identity's wire
821 # shape does not matter yet.
822 identity_can_transmit = bool(identity[CONF_COMMANDS]) or identity[CONF_ENROLLMENT]
823 if (
824 manufacturer_explicit
825 and identity[CONF_MANUFACTURER] not in _ONEWAY_WIRE_PROFILE_MANUFACTURERS
826 and identity_can_transmit
827 ):
828 _LOGGER.warning(
829 "home_io_control: oneway_controllers '%s' has manufacturer 0x%02X, which has no "
830 "1W wire profile -- using the Somfy-shaped defaults (ACEI 0x43). Set execute_acei: "
831 "explicitly if that is wrong for your device.",
832 identity_id,
833 identity[CONF_MANUFACTURER],
834 )
835
836 # A VELUX enrollment 0x30 sweep goes to {roller_shutter, awning, dual_shutter} and never
837 # screen/blind/venetian_blind -- no VELUX remote pairs on those classes (issue #74 capture).
838 # If this identity is a screen/blind that
839 # will enroll and hasn't narrowed the sweep itself, say so: enrollment ignores io_device_type.
840 if (
841 identity[CONF_MANUFACTURER] == MANUFACTURER_OPTIONS["velux"]
842 and identity[CONF_ENROLLMENT]
843 and CONF_ENROLLMENT_CLASSES not in identity
844 and identity[CONF_IO_DEVICE_TYPE]
845 in (
846 DEVICE_TYPE_OPTIONS["screen"],
847 DEVICE_TYPE_OPTIONS["blind"],
848 DEVICE_TYPE_OPTIONS["venetian_blind"],
849 )
850 ):
851 _LOGGER.warning(
852 "home_io_control: oneway_controllers '%s' is a VELUX %s with enrollment: true. The "
853 "enrollment 0x30 sweep will target roller_shutter/awning/dual_shutter, NOT this "
854 "io_device_type -- no VELUX remote pairs on screen/blind. Set enrollment_classes: "
855 "explicitly (e.g. [awning]) if you know which class your actuator listens on.",
856 identity_id,
857 next(
858 name
859 for name, value in DEVICE_TYPE_OPTIONS.items()
860 if value == identity[CONF_IO_DEVICE_TYPE]
861 ),
862 )
863
864 # enrollment_classes: only feeds the VELUX enrollment gesture. On a somfy/unprofiled
865 # identity, or one with no enroll button at all, it is silently inert -- flag it, the same
866 # way the screen/blind case above is flagged.
867 if CONF_ENROLLMENT_CLASSES in identity and (
868 identity[CONF_MANUFACTURER] != MANUFACTURER_OPTIONS["velux"]
869 or not identity[CONF_ENROLLMENT]
870 ):
871 _LOGGER.warning(
872 "home_io_control: oneway_controllers '%s' sets enrollment_classes: but it only "
873 "affects the VELUX enrollment gesture (needs manufacturer: velux AND "
874 "enrollment: true) -- it is ignored here.",
875 identity_id,
876 )
877
878 # Entity IDs are declared here, at validation time, not in to_code(): an ID created late
879 # is silently dropped at runtime (ADR 0009). The `<identity_id>_<command>` shape is a
880 # documented contract, not an implementation detail -- users compose `time_based` covers
881 # against these IDs and cannot do that against IDs they can't predict.
882 identity[CONF_BUTTON_IDS] = {
883 command: ID(
884 f"{identity_id}_{command}",
885 is_declaration=True,
886 type=IOHomeOneWayCommandButton,
887 )
888 for command in identity[CONF_COMMANDS]
889 }
890 identity[CONF_LAST_COMMAND_SENSOR_ID] = ID(
891 f"{identity_id}_last_1w_command",
892 is_declaration=True,
893 type=IOHomeOneWayLastCommandTextSensor,
894 )
895 if identity[CONF_ENROLLMENT]:
896 identity[CONF_ENROLL_BUTTON_ID] = ID(
897 f"{identity_id}_enroll",
898 is_declaration=True,
899 type=IOHomeOneWayEnrollButton,
900 )
901
902 return config
903
904
905# cover:/light:/lock:/switch: are the device-bound platforms that can carry an io_device_id: or a
906# linked_remotes: entry (platform_common.py). button:/number:/select:/sensor:/text_sensor: are
907# either not device-bound or, for this component, hub-level only.
908_DEVICE_BOUND_DOMAINS = ("cover", "light", "lock", "switch")
909
910
911def _collect_declared_device_addresses(full_config):
912 """Map every node ID declared as an `io_device_id:` or a bare `linked_remotes:` entry, across
913 every `home_io_control` entity in `full_config`, to a human-readable owner string.
914
915 Only entries with `platform: home_io_control` are considered — the domains in
916 _DEVICE_BOUND_DOMAINS are shared with every other component that provides a cover/light/
917 lock/switch platform, and those have nothing to do with this component's address space.
918 `class:` linked_remotes entries name a device *type*, not a node, so they carry no address to
919 collide with and are skipped.
920
921 CONF_IO_DEVICE_ID/CONF_LINKED_REMOTES are imported locally from platform_common rather than at
922 module level: platform_common imports back from this module (`from . import ...`), so a
923 module-level import here would be circular. By the time this function actually runs (final
924 validation, after every used platform module has already been imported), the cycle has
925 already resolved and the import is a plain cache hit.
926 """
927 from .platform_common import CONF_IO_DEVICE_ID, CONF_LINKED_REMOTES
928
929 addresses = {}
930 for domain in _DEVICE_BOUND_DOMAINS:
931 for entry in full_config.get(domain, []):
932 if not isinstance(entry, dict) or entry.get(CONF_PLATFORM) != "home_io_control":
933 continue
934 owner_name = entry.get(CONF_NAME) or entry.get(CONF_ID) or "<unnamed>"
935 device_id = entry.get(CONF_IO_DEVICE_ID)
936 if device_id:
937 addresses[device_id] = f"{domain} '{owner_name}' io_device_id"
938 for remote in entry.get(CONF_LINKED_REMOTES, []):
939 if remote.startswith("class:"):
940 continue
941 addresses[remote] = f"{domain} '{owner_name}' linked_remotes"
942 return addresses
943
944
945def _final_validate_oneway_controller_addresses(config):
946 """Extend the oneway_controllers address-collision check to addresses declared outside
947 `home_io_control:` — a `linked_remotes:` entry or an `io_device_id:` on some other entity in
948 this same YAML.
949
950 Runs as FINAL_VALIDATE_SCHEMA rather than inside _validate_oneway_controllers() because only
951 final validation has access to the full cross-component config (`fv.full_config`) —
952 `cover:`/`light:`/`lock:`/`switch:` entries are validated independently of
953 `home_io_control:`'s own CONFIG_SCHEMA and are not visible to it. By this point
954 _validate_oneway_controllers() has already run, so every identity's `node_id` (derived or
955 explicit) is resolved.
956 """
957 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
958 if not identities:
959 return config
960
961 declared = _collect_declared_device_addresses(fv.full_config.get())
962 for identity in identities:
963 _reject_node_id_collision(
964 identity[CONF_ID], identity[CONF_NODE_ID], declared, identity.get(CONF_NODE_ID_DERIVED)
965 )
966 return config
967
968
969FINAL_VALIDATE_SCHEMA = _final_validate_oneway_controller_addresses
970
971
972def validate_device_id(value):
973 """Validate io_device_id as exactly 6 hex characters (3 bytes)."""
974 value = cv.string_strict(value).upper()
975 if len(value) != 6:
976 raise cv.Invalid("Device ID must be exactly 6 hex characters (3 bytes)")
977 try:
978 int(value, 16)
979 except ValueError:
980 raise cv.Invalid("Device ID must be valid hexadecimal")
981 return value
982
983
984def inherit_esphome_device(companion_config, parent_config):
985 """Propagate the parent entity's ESPHome sub-device (YAML `device_id:`) onto a hand-built
986 companion config dict, so the companion entity groups under the same HA device as its parent.
987
988 Lives here rather than in platform_common.py: it is needed by button.py's pairing-result
989 sensor, which is not a device-bound platform and would otherwise have to import the whole
990 platform-schema module for a four-line helper. platform_common.py re-exports it so cover.py's
991 existing import keeps working.
992
993 Companion entities (diagnostic sensors, cover favorite/vent buttons, ...) are built from
994 dicts fed straight to e.g. new_text_sensor()/new_button() rather than through the platform's
995 own cv.Schema(), so they never go through ENTITY_BASE_SCHEMA and never pick up `device_id:`
996 on their own. esphome.core.entity_helpers.setup_entity() reads it with
997 `config.get(CONF_DEVICE_ID)`, a truthiness check, so an explicit `None` and an absent key
998 behave identically; omitted here rather than set to None just to keep the dict shape
999 identical to a companion with no sub-device at all.
1000
1001 Deliberately not called anywhere for the hub-level dynamic entities (1W identity buttons/
1002 sensors, the arming switches, LR1121 firmware controls, tuning numbers/selects): none of their
1003 parent configs carry a `device_id:` schema slot, since those entities aren't attached to a
1004 single cover/light/switch/lock to inherit one from. `device_id:` grouping is scoped to the
1005 four device-bound platforms; hub-level entities always live on ESPHome's main device.
1006 """
1007 if (esphome_device_id := parent_config.get(CONF_DEVICE_ID)) is not None:
1008 companion_config[CONF_DEVICE_ID] = esphome_device_id
1009 return companion_config
1010
1011
1012def validate_linked_remote_entry(value):
1013 """Validate a linked_remotes entry: either a device ID or 'class:<device_type>'.
1014
1015 The class form matches how 1W remotes address a typed broadcast (e.g. "all awnings")
1016 rather than a single node, so one entry can cover many same-type devices without
1017 enumerating each one. Shares _resolve_device_type_token() with validate_device_type()
1018 so a type without a named YAML alias yet (e.g. discovered via pairing) can still be
1019 class-linked. Normalized to 'class:0x<HH>' (uppercase hex) so wire_device_binding() can
1020 parse the type directly without a second DEVICE_TYPE_OPTIONS lookup; bare device IDs are
1021 validated exactly as before and behave identically.
1022 """
1023 if isinstance(value, str) and value.lower().startswith("class:"):
1024 type_token = value.split(":", 1)[1].strip().lower()
1025 try:
1026 type_value = _resolve_device_type_token(type_token)
1027 except ValueError as err:
1028 raise cv.Invalid(
1029 f"Unknown device class '{type_token}' in linked_remotes; expected one of: "
1030 + ", ".join(sorted(DEVICE_TYPE_OPTIONS))
1031 + ", or a raw integer such as 0x14"
1032 ) from err
1033 return f"class:0x{type_value:02X}"
1034 return validate_device_id(value)
1035
1036
1037def validate_status_poll_interval(value):
1038 """Validate status_poll_interval is at least MIN_STATUS_POLL_INTERVAL_MS."""
1039 value = cv.positive_time_period_milliseconds(value)
1040 if value.total_milliseconds < MIN_STATUS_POLL_INTERVAL_MS:
1041 raise cv.Invalid(
1042 f"status_poll_interval must be at least {MIN_STATUS_POLL_INTERVAL_MS}ms"
1043 )
1044 return value
1045
1046
1047CONFIG_SCHEMA = cv.All(
1048 cv.Schema(
1049 {
1050 cv.GenerateID(): cv.declare_id(IOHomeControlComponent),
1051 cv.Required(CONF_RST_PIN): pins.internal_gpio_output_pin_schema,
1052 cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema,
1053 cv.Optional(CONF_DIO4_PIN): pins.internal_gpio_input_pin_schema,
1054 # The chip's IRQ line: SX1262's DIO1, or LR1121's DIO9
1055 cv.Optional(CONF_DIO1_PIN): pins.internal_gpio_input_pin_schema,
1056 cv.Optional(CONF_BUSY_PIN): pins.internal_gpio_input_pin_schema,
1057 cv.Required(CONF_NODE_ID): validate_node_id,
1058 cv.Required(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
1059 cv.Optional(CONF_TX_POWER, default=17): cv.int_range(min=0, max=22),
1060 cv.Optional(CONF_PA_PIN, default="BOOST"): cv.enum(
1061 PA_PIN_OPTIONS, upper=True
1062 ),
1063 cv.Required(CONF_RADIO_TYPE): cv.enum(RADIO_TYPE_OPTIONS, lower=True),
1064 cv.Optional(CONF_FEM_EN_PIN): pins.internal_gpio_output_pin_schema,
1065 cv.Optional(CONF_VFEM_PIN): pins.internal_gpio_output_pin_schema,
1066 cv.Optional(CONF_FEM_PA_PIN): pins.internal_gpio_output_pin_schema,
1067 cv.Optional(CONF_TCXO_VOLTAGE, default="1_8V"): cv.enum(
1068 TCXO_VOLTAGE_OPTIONS, upper=True
1069 ),
1070 cv.Optional(CONF_EXPOSED_SENDERS, default=[]): cv.ensure_list(
1071 validate_device_id
1072 ),
1073 cv.Optional(CONF_ACCEPT_FOREIGN_PAIRING, default=False): cv.boolean,
1074 cv.Optional(CONF_RECOVER_ONEWAY_KEY, default=False): cv.boolean,
1075 cv.Optional(CONF_SCAN_PAIRED_DEVICES_BUTTON, default=False): cv.boolean,
1076 cv.Optional(CONF_ONEWAY_CONTROLLERS, default=[]): cv.ensure_list(
1077 ONEWAY_CONTROLLER_SCHEMA
1078 ),
1079 cv.Optional(CONF_DIAGNOSTIC_PROBES, default=False): cv.boolean,
1080 cv.Optional(CONF_LR1121_FIRMWARE_UPDATE): LR1121_FIRMWARE_UPDATE_SCHEMA,
1081 cv.Optional(tuning_module.CONF_TUNING): tuning_module.TUNING_CONFIG_SCHEMA,
1082 }
1083 )
1084 .extend(cv.COMPONENT_SCHEMA)
1085 .extend(spi.spi_device_schema(True, 8e6, "mode0")),
1086 _inject_accept_foreign_pairing_switch_id,
1087 _inject_recover_oneway_key_switch_id,
1088 _inject_scan_paired_devices_button_id,
1089 _validate_oneway_controllers,
1090 _validate_lr1121_firmware_update,
1091)
1092
1093
1094async def to_code(config):
1095 # Hub-level management actions and result events are compiled behind native API
1096 # feature flags. Home IO Control enables the required compile-time switches here
1097 # so users only need a normal `api:` block in YAML.
1098 # ESPHome 2026.x additionally gates user-defined actions behind
1099 # USE_API_USER_DEFINED_ACTIONS.
1100 cg.add_define("USE_API_USER_DEFINED_ACTIONS")
1101 cg.add_define("USE_API_CUSTOM_SERVICES")
1102 cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
1103
1104 var = cg.new_Pvariable(config[CONF_ID])
1105 await cg.register_component(var, config)
1106 await spi.register_spi_device(var, config)
1107
1108 rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN])
1109 cg.add(var.set_rst_pin(rst_pin))
1110
1111 if CONF_DIO0_PIN in config:
1112 dio0_pin = await cg.gpio_pin_expression(config[CONF_DIO0_PIN])
1113 cg.add(var.set_dio0_pin(dio0_pin))
1114
1115 if CONF_DIO4_PIN in config:
1116 dio4_pin = await cg.gpio_pin_expression(config[CONF_DIO4_PIN])
1117 cg.add(var.set_dio4_pin(dio4_pin))
1118
1119 if CONF_DIO1_PIN in config:
1120 dio1_pin = await cg.gpio_pin_expression(config[CONF_DIO1_PIN])
1121 cg.add(var.set_dio1_pin(dio1_pin))
1122
1123 if CONF_BUSY_PIN in config:
1124 busy_pin = await cg.gpio_pin_expression(config[CONF_BUSY_PIN])
1125 cg.add(var.set_busy_pin(busy_pin))
1126
1127 if CONF_FEM_EN_PIN in config:
1128 fem_en_pin = await cg.gpio_pin_expression(config[CONF_FEM_EN_PIN])
1129 cg.add(var.set_fem_en_pin(fem_en_pin))
1130
1131 if CONF_VFEM_PIN in config:
1132 vfem_pin = await cg.gpio_pin_expression(config[CONF_VFEM_PIN])
1133 cg.add(var.set_vfem_pin(vfem_pin))
1134
1135 if CONF_FEM_PA_PIN in config:
1136 fem_pa_pin = await cg.gpio_pin_expression(config[CONF_FEM_PA_PIN])
1137 cg.add(var.set_fem_pa_pin(fem_pa_pin))
1138
1139 cg.add(var.set_node_id(config[CONF_NODE_ID]))
1140 cg.add(var.set_system_key(config[CONF_SYSTEM_KEY]))
1141 cg.add(var.set_tx_power(config[CONF_TX_POWER]))
1142 cg.add(var.set_pa_pin(config[CONF_PA_PIN]))
1143
1144 cg.add(var.set_radio_type(config[CONF_RADIO_TYPE]))
1145
1146 cg.add(var.set_tcxo_voltage(config[CONF_TCXO_VOLTAGE]))
1147
1148 for sender_id in config[CONF_EXPOSED_SENDERS]:
1149 cg.add(var.add_exposed_sender(sender_id))
1150
1151 for identity in config[CONF_ONEWAY_CONTROLLERS]:
1152 cg.add(
1153 var.add_oneway_controller(
1154 oneway_controller_expression(identity, config[CONF_NODE_ID])
1155 )
1156 )
1157 await _create_oneway_controller_entities(identity, var)
1158
1159 if config[CONF_ACCEPT_FOREIGN_PAIRING]:
1161 config,
1162 var,
1163 cls=IOHomeAcceptForeignPairingSwitch,
1164 id_key=CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID,
1165 name="Recover System Key",
1166 )
1167
1168 if config[CONF_RECOVER_ONEWAY_KEY]:
1170 config,
1171 var,
1172 cls=IOHomeRecoverOneWayKeySwitch,
1173 id_key=CONF_RECOVER_ONEWAY_KEY_SWITCH_ID,
1174 name="Recover 1W Controller Key",
1175 )
1176
1177 if config[CONF_SCAN_PAIRED_DEVICES_BUTTON]:
1178 await _create_scan_paired_devices_button(config, var)
1179
1180 cg.add(var.set_diagnostic_probes_enabled(config[CONF_DIAGNOSTIC_PROBES]))
1181
1182 if CONF_LR1121_FIRMWARE_UPDATE in config:
1183 await _create_lr1121_firmware_update(config, var)
1184
1185 if tuning_module.CONF_TUNING in config:
1186 await tuning_module.to_code(config[tuning_module.CONF_TUNING], var)
1187
1188
1189async def _create_oneway_controller_entities(identity, var):
1190 """Create one identity's command buttons and its "Last 1W Command" diagnostic sensor.
1191
1192 Same normalization as the hub-level switches below: run a bare {id, name} dict through the
1193 platform's own schema so it carries the entity/component defaults register_*() require.
1194
1195 Entity names derive from the identity handle and the command ("awning_remote" + "open" ->
1196 "Awning Remote Open"), mirroring how the cover's favourite/vent companions derive theirs. The
1197 *IDs* follow the documented `<identity_id>_<command>` rule instead, because those are what a
1198 `time_based` cover composes against.
1199 """
1200 friendly_identity = identity[CONF_ID].replace("_", " ").title()
1201
1202 for command, button_id in identity[CONF_BUTTON_IDS].items():
1203 entity_config = button_component.button_schema(
1204 IOHomeOneWayCommandButton,
1205 ).extend(cv.COMPONENT_SCHEMA)(
1206 {
1207 CONF_ID: button_id,
1208 CONF_NAME: f"{friendly_identity} {command.replace('_', ' ').title()}",
1209 }
1210 )
1211 entity = await button_component.new_button(entity_config)
1212 await cg.register_component(entity, entity_config)
1213 cg.add(entity.set_parent(var))
1214 cg.add(entity.set_controller_id(identity[CONF_ID]))
1215 cg.add(entity.set_action(ONEWAY_COMMANDS[command]))
1216
1217 # Always created, even with no buttons: an identity driven only by the
1218 # `oneway_set_position` action still needs somewhere to show what it sent, and with no reply
1219 # frame this sensor is the only place that can ever appear.
1220 sensor_config = text_sensor_component.text_sensor_schema(
1221 IOHomeOneWayLastCommandTextSensor,
1222 entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
1223 ).extend(cv.COMPONENT_SCHEMA)(
1224 {
1225 CONF_ID: identity[CONF_LAST_COMMAND_SENSOR_ID],
1226 CONF_NAME: f"{friendly_identity} Last 1W Command",
1227 }
1228 )
1229 sensor = await text_sensor_component.new_text_sensor(sensor_config)
1230 await cg.register_component(sensor, sensor_config)
1231 cg.add(sensor.set_parent(var))
1232 cg.add(sensor.set_controller_id(identity[CONF_ID]))
1233
1234 if identity[CONF_ENROLLMENT]:
1235 # entity_category: config, not a switch behind an arming flag: the receiver's own 2s PROG
1236 # hold is the real interlock -- a hub cannot enroll into a device nobody has walked up to
1237 # (ADR 0026). ADR 0021's bootloader precedent doesn't apply here (that ADR is for an
1238 # irreversible write; un-enrollment exists here as a rollback path).
1239 #
1240 # No "(May Replace Existing Remotes)" caveat: enrolling a new identity onto a device is
1241 # additive, not destructive -- an existing remote keeps working alongside a newly enrolled
1242 # hub. Un-enrollment (`0x39`, the `oneway_remove_controller` action) has not been confirmed
1243 # to work on real hardware yet -- see that action's own doxygen (management_actions.h) --
1244 # so "reversible" is the design intent, not yet a demonstrated fact.
1245 enroll_config = button_component.button_schema(
1246 IOHomeOneWayEnrollButton,
1247 entity_category=ENTITY_CATEGORY_CONFIG,
1248 ).extend(cv.COMPONENT_SCHEMA)(
1249 {
1250 CONF_ID: identity[CONF_ENROLL_BUTTON_ID],
1251 CONF_NAME: f"{friendly_identity} Enroll 1W Controller",
1252 }
1253 )
1254 enroll_entity = await button_component.new_button(enroll_config)
1255 await cg.register_component(enroll_entity, enroll_config)
1256 cg.add(enroll_entity.set_parent(var))
1257 cg.add(enroll_entity.set_controller_id(identity[CONF_ID]))
1258
1259
1260async def _create_hub_arming_switch(config, var, *, cls, id_key, name):
1261 """Create a hub-level arming switch (key extraction or key adoption).
1262
1263 Mirrors tuning.py's _create_number()/_create_select(): normalize a bare {id, name} dict
1264 through switch_schema()+COMPONENT_SCHEMA so it carries the entity/component defaults
1265 register_switch()/register_component() require, matching the neighboring pattern rather than
1266 hand-assembling a config dict shape of its own.
1267
1268 ALWAYS_OFF is a security property, not a UX default: every switch built here arms a window
1269 (foreign-key extraction or 1W key adoption) that must never come back armed after a reboot.
1270 """
1271 entity_config = switch_component.switch_schema(
1272 cls,
1273 default_restore_mode="ALWAYS_OFF", # never auto-arm after a reboot
1274 entity_category=ENTITY_CATEGORY_CONFIG,
1275 ).extend(cv.COMPONENT_SCHEMA)(
1276 {
1277 CONF_ID: config[id_key],
1278 CONF_NAME: name,
1279 }
1280 )
1281 entity = await switch_component.new_switch(entity_config)
1282 await cg.register_component(entity, entity_config)
1283 cg.add(entity.set_parent(var))
1284
1285
1287 """Create the hub-level "Scan Paired Devices" button.
1288
1289 Same normalization as _create_hub_arming_switch() above: run a bare {id, name} dict through
1290 button_schema()+COMPONENT_SCHEMA so it carries the entity/component defaults register_button()/
1291 register_component() require. The `scan_paired_devices` native API action is registered
1292 independently in C++ (ManagementActions::register_actions()) and is unaffected by this key --
1293 the button is an extra trigger onto the same method, not a replacement.
1294 """
1295 entity_config = button_component.button_schema(
1296 IOHomeScanPairedDevicesButton,
1297 entity_category=ENTITY_CATEGORY_CONFIG,
1298 ).extend(cv.COMPONENT_SCHEMA)(
1299 {
1300 CONF_ID: config[CONF_SCAN_PAIRED_DEVICES_BUTTON_ID],
1301 CONF_NAME: "Scan Paired Devices",
1302 }
1303 )
1304 entity = await button_component.new_button(entity_config)
1305 await cg.register_component(entity, entity_config)
1306 cg.add(entity.set_parent(var))
1307
1308
1309def _cached_http_fetch(cache_dir):
1310 """Build a `fetch(url, expected_hash=None) -> bytes` callable for
1311 lr1121_firmware.fetch_and_verify(), backed by an on-disk cache so repeat and offline builds
1312 don't re-download the same source.
1313
1314 The cache key incorporates `expected_hash` (the MD5 fetch_and_verify() already resolved from
1315 the `.md5` sidecar or `checksum_md5:` before calling this for the `.bin`) rather than being
1316 `sha256(url)` alone. With the default `ref: HEAD` the URL never changes, so a plain
1317 url-only key means a corrupt/truncated download poisons the cache permanently -- no config
1318 change can ever invalidate it, since nothing about the request changes on retry. Folding the
1319 expected hash in means correcting a wrong `checksum_md5:` (or a fixed upstream sidecar) misses
1320 the poisoned entry and forces a fresh download. The `.md5` sidecar fetch itself has no
1321 expected_hash to key on (chicken-and-egg -- it's what supplies one for the .bin) and is cached
1322 under the URL alone; a corrupted sidecar is a much smaller/rarer risk than a corrupted 64+ KB
1323 binary, and the cache directory below is a manual escape hatch either way.
1324
1325 Data that fails its own hash check is deliberately never written to the cache (verify-before-store):
1326 a transient network corruption then simply retries cleanly on the next build, with no
1327 config change needed at all.
1328 """
1329 cache_dir.mkdir(parents=True, exist_ok=True)
1330
1331 def fetch(url, expected_hash=None):
1332 cache_key = hashlib.sha256(f"{url}|{expected_hash or ''}".encode("utf-8")).hexdigest()
1333 cache_path = cache_dir / cache_key
1334 if cache_path.exists():
1335 return cache_path.read_bytes()
1336 try:
1337 with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
1338 data = response.read()
1339 except urllib.error.HTTPError as err:
1340 if err.code == 404:
1342 raise lr1121_firmware.Lr1121FirmwareError(f"HTTP {err.code} fetching {url}") from err
1343 except urllib.error.URLError as err:
1344 raise lr1121_firmware.Lr1121FirmwareError(f"Failed to fetch {url}: {err}") from err
1345 if expected_hash is None or hashlib.md5(data).hexdigest() == expected_hash: # noqa: S324
1346 cache_path.write_bytes(data)
1347 return data
1348
1349 return fetch
1350
1351
1352def _render_lr1121_image_header(image, array_name, words_name, version_name):
1353 """Render a verified firmware/loader image as a C++ header.
1354
1355 Each raw 4-byte chunk of the `.bin` is exactly one big-endian word as Semtech's own image
1356 format already lays it out, so this only has to slice and format, not transform, the bytes.
1357 `inline const` (not `constexpr`) for the array: it is never used in a constant expression, so
1358 forcing constant-evaluation of up to ~61k elements would only cost compile time; `const` at
1359 namespace scope still lands in `.rodata` (flash) on ESP32, not RAM. Shared by
1360 _render_lr1121_firmware_header() (the transceiver image) and the bootloader loader image --
1361 same shape, different symbol names so both headers can be included from the same translation
1362 unit without colliding.
1363 """
1364 words = [f"0x{int.from_bytes(image.data[i : i + 4], 'big'):08X}" for i in range(0, len(image.data), 4)]
1365 words_per_line = 8
1366 body_lines = [
1367 " " + ", ".join(words[i : i + words_per_line]) + "," for i in range(0, len(words), words_per_line)
1368 ]
1369 return "\n".join(
1370 [
1371 "#pragma once",
1372 "// Auto-generated by the home_io_control lr1121_firmware_update build step. Do not edit.",
1373 "#include <cstddef>",
1374 "#include <cstdint>",
1375 "",
1376 "namespace esphome {",
1377 "namespace home_io_control {",
1378 "",
1379 f"inline const uint32_t {array_name}[] = {{",
1380 *body_lines,
1381 "};",
1382 f"inline constexpr size_t {words_name} = {len(words)};",
1383 f"inline constexpr uint16_t {version_name} = 0x{image.version:04X};",
1384 "",
1385 "} // namespace home_io_control",
1386 "} // namespace esphome",
1387 "",
1388 ]
1389 )
1390
1391
1393 """Render the verified transceiver firmware image as a C++ header."""
1395 image, "LR1121_FIRMWARE_UPDATE_IMAGE", "LR1121_FIRMWARE_UPDATE_IMAGE_WORDS", "LR1121_FIRMWARE_UPDATE_TARGET_VERSION"
1396 )
1397
1398
1400 """Render the verified bootloader *loader* image as a C++ header (ADR 0021)."""
1402 image, "LR1121_BOOTLOADER_LOADER_IMAGE", "LR1121_BOOTLOADER_LOADER_IMAGE_WORDS", "LR1121_BOOTLOADER_LOADER_FW"
1403 )
1404
1405
1406async def _create_lr1121_firmware_update(config, var):
1407 """Fetch/verify the configured firmware image, generate its header, set the build flag that
1408 gates the whole feature, and create the "Flash LR1121 Radio Firmware" button.
1409
1410 The block's mere presence in YAML is the build flag (ADR 0020) — there is no
1411 separate enable switch, so entering/leaving flash mode is a recompile + OTA each way.
1412 """
1413 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
1414 cache_dir = CORE.data_dir / "lr1121_firmware_cache"
1415 try:
1416 image = lr1121_firmware.fetch_and_verify(
1417 source=fw_config[CONF_SOURCE],
1418 ref=fw_config.get(CONF_REF),
1419 checksum_md5=fw_config.get(CONF_CHECKSUM_MD5),
1420 target_version=fw_config.get(CONF_TARGET_VERSION),
1421 fetch=_cached_http_fetch(cache_dir),
1422 )
1424 raise cv.Invalid(f"lr1121_firmware_update: {err}") from err
1425
1426 header_path = CORE.relative_src_path("lr1121_firmware_update_image.h")
1427 write_file_if_changed(header_path, _render_lr1121_firmware_header(image))
1428
1429 cg.add_define("IOHOME_LR1121_FIRMWARE_UPDATE")
1430
1431 if CONF_LR1121_BOOTLOADER in fw_config:
1432 await _create_lr1121_bootloader_update(fw_config[CONF_LR1121_BOOTLOADER], config, var, cache_dir)
1433
1434 entity_config = button_component.button_schema(
1435 IOHomeLr1121FirmwareUpdateButton,
1436 entity_category=ENTITY_CATEGORY_CONFIG,
1437 ).extend(cv.COMPONENT_SCHEMA)(
1438 {
1439 CONF_ID: config[CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID],
1440 CONF_NAME: "Flash LR1121 Radio Firmware",
1441 }
1442 )
1443 entity = await button_component.new_button(entity_config)
1444 await cg.register_component(entity, entity_config)
1445 cg.add(entity.set_parent(var))
1446
1447
1448async def _create_lr1121_bootloader_update(bootloader_config, config, var, cache_dir):
1449 """Fetch/verify the configured loader image, generate its header, set the build flag that
1450 gates the bootloader-rewrite feature, and create the arming switch.
1451
1452 Mirrors _create_lr1121_firmware_update() above -- same "block's presence is the build flag"
1453 shape, one level down (ADR 0021). `target_version` is not passed to
1454 fetch_and_verify(): the loader is not a "target" the way the transceiver image is, its version
1455 is only ever compared for *equality* against the currently-running bootloader (Semtech's
1456 rule), so there is nothing to override.
1457 """
1458 try:
1459 loader_image = lr1121_firmware.fetch_and_verify(
1460 source=bootloader_config[CONF_SOURCE],
1461 ref=bootloader_config.get(CONF_REF),
1462 checksum_md5=bootloader_config.get(CONF_CHECKSUM_MD5),
1463 target_version=None,
1464 fetch=_cached_http_fetch(cache_dir),
1465 )
1467 raise cv.Invalid(f"lr1121_firmware_update.bootloader: {err}") from err
1468
1469 header_path = CORE.relative_src_path("lr1121_bootloader_loader_image.h")
1470 write_file_if_changed(header_path, _render_lr1121_bootloader_loader_header(loader_image))
1471
1472 cg.add_define("IOHOME_LR1121_BOOTLOADER_UPDATE")
1473
1474 entity_config = switch_component.switch_schema(
1475 IOHomeLr1121BootloaderRewriteSwitch,
1476 default_restore_mode="ALWAYS_OFF", # never auto-arm after a reboot -- ADR 0021
1477 entity_category=ENTITY_CATEGORY_CONFIG,
1478 ).extend(cv.COMPONENT_SCHEMA)(
1479 {
1480 CONF_ID: config[CONF_LR1121_BOOTLOADER_SWITCH_ID],
1481 CONF_NAME: "Allow LR1121 Bootloader Rewrite (Irreversible)",
1482 # Deliberately NOT disabled_by_default. It reads like the right call for an irreversible
1483 # control, but in Home Assistant that disables the entity in the registry: it cannot be
1484 # toggled until the user finds it and enables it by hand, which makes the documented
1485 # procedure ("turn the switch on, press the button") simply not work. It also defeats
1486 # ADR 0021's reason for choosing a switch over an invisible confirmation window -- that
1487 # the armed state is answerable by looking -- since a disabled entity is not shown at
1488 # all. entity_category=config is the right amount of out-of-the-way: it files the switch
1489 # under Configuration rather than among the primary controls, and it stays usable.
1490 # The real gating is elsewhere and unaffected: the bootloader: block must be in YAML and
1491 # the firmware rebuilt, and the switch is off on every boot (ALWAYS_OFF).
1492 }
1493 )
1494 entity = await switch_component.new_switch(entity_config)
1495 await cg.register_component(entity, entity_config)
1496 cg.add(entity.set_parent(var))
_render_lr1121_bootloader_loader_header(image)
Definition __init__.py:1399
_render_lr1121_image_header(image, array_name, words_name, version_name)
Definition __init__.py:1352
_render_lr1121_firmware_header(image)
Definition __init__.py:1392
to_code(config)
Definition __init__.py:1094
_create_lr1121_bootloader_update(bootloader_config, config, var, cache_dir)
Definition __init__.py:1448
_create_lr1121_firmware_update(config, var)
Definition __init__.py:1406
validate_checksum_md5(value)
Definition __init__.py:266
_create_oneway_controller_entities(identity, var)
Definition __init__.py:1189
_inject_hub_entity_id(config, *, flag_key, id_key, suffix, cls)
Definition __init__.py:202
_inject_scan_paired_devices_button_id(config)
Definition __init__.py:237
_cached_http_fetch(cache_dir)
Definition __init__.py:1309
_create_hub_arming_switch(config, var, *, cls, id_key, name)
Definition __init__.py:1260
_inject_recover_oneway_key_switch_id(config)
Definition __init__.py:227
_inject_accept_foreign_pairing_switch_id(config)
Definition __init__.py:217
validate_lr1121_firmware_source(value, *, expect_loader=False)
Definition __init__.py:247
_create_scan_paired_devices_button(config, var)
Definition __init__.py:1286