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
15from esphome import pins
16from esphome.components import spi
17# Aliased: this package has its own switch.py/button.py submodules, so a plain `from
18# esphome.components import switch` here would bind the name `switch` in this __init__.py's
19# namespace — which, because __init__.py IS the esphome.components.home_io_control package
20# object, is the exact same slot ESPHome's component loader later overwrites when it imports our
21# own switch.py/button.py platform files as `esphome.components.home_io_control.switch` /
22# `...button`. Without the alias, whichever import happens to run last silently wins, and
23# to_code() (called even later, when tasks are flushed) can end up resolving `switch`/`button` to
24# our own platform files instead of the real ESPHome components.
25from esphome.components import button as button_component
26from esphome.components import switch as switch_component
27from esphome.const import (
28 CONF_ID,
29 CONF_INVERTED,
30 CONF_NAME,
31 CONF_REF,
32 CONF_SOURCE,
33 ENTITY_CATEGORY_CONFIG,
34)
35from esphome.core import CORE, ID
36from esphome.helpers import write_file_if_changed
37
38from . import lr1121_firmware
39from . import tuning as tuning_module
40
41_LOGGER = logging.getLogger(__name__)
42
43DEPENDENCIES = ["api", "spi"]
44AUTO_LOAD = ["button", "cover", "light", "lock", "number", "select", "sensor", "switch", "text_sensor"]
45MULTI_CONF = False
46
47CONF_HOME_IO_CONTROL_ID = "home_io_control_id"
48CONF_RST_PIN = "rst_pin"
49CONF_DIO0_PIN = "dio0_pin"
50CONF_DIO4_PIN = "dio4_pin"
51CONF_DIO1_PIN = "dio1_pin"
52CONF_BUSY_PIN = "busy_pin"
53CONF_NODE_ID = "node_id"
54CONF_SYSTEM_KEY = "system_key"
55CONF_TX_POWER = "tx_power"
56CONF_PA_PIN = "pa_pin"
57CONF_RADIO_TYPE = "radio_type"
58CONF_FEM_EN_PIN = "fem_en_pin"
59CONF_VFEM_PIN = "vfem_pin"
60CONF_FEM_PA_PIN = "fem_pa_pin"
61CONF_TCXO_VOLTAGE = "tcxo_voltage"
62CONF_EXPOSED_SENDERS = "exposed_senders"
63CONF_ACCEPT_FOREIGN_PAIRING = "accept_foreign_pairing"
64CONF_LR1121_FIRMWARE_UPDATE = "lr1121_firmware_update"
65CONF_LR1121_BOOTLOADER = "bootloader"
66CONF_CHECKSUM_MD5 = "checksum_md5"
67CONF_TARGET_VERSION = "target_version"
68MIN_STATUS_POLL_INTERVAL_MS = 500
69
70# Internal config key for the "Accept Foreign Pairing" companion switch ID (injected by
71# post-validator, same pattern as tuning.py's companion entity IDs — ESPHome 2026.x sizes the
72# runtime component vector from IDs known at the end of schema validation, so a companion
73# entity created only in to_code() would silently drop; see tuning.py::_inject_tuning_companion_ids
74# for the fuller rationale).
75CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID = "_accept_foreign_pairing_switch_id"
76# Internal config key for the "Flash LR1121 Radio Firmware" companion button ID (injected by
77# post-validator; same rationale as CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID above).
78CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID = "_lr1121_firmware_update_button_id"
79# Internal config key for the "Allow LR1121 Bootloader Rewrite (Irreversible)" companion switch ID
80# (injected by post-validator; same rationale as CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID above --
81# only present when lr1121_firmware_update.bootloader: is configured).
82CONF_LR1121_BOOTLOADER_SWITCH_ID = "_lr1121_bootloader_switch_id"
83
84home_io_control_ns = cg.esphome_ns.namespace("home_io_control")
85IOHomeControlComponent = home_io_control_ns.class_(
86 "IOHomeControlComponent", cg.Component, spi.SPIDevice
87)
88# Hub-level "Accept Foreign Pairing (Key Extraction)" switch (hub_key_extraction.cpp /
89# platform_accept_foreign_pairing_switch.h). Deliberately NOT exposed via a `switch:` platform
90# entry: earlier revisions dispatched on the presence/absence of `io_device_id` within switch.py,
91# which meant an ordinary device-bound switch missing `io_device_id` by mistake would silently
92# become this security-sensitive switch instead of failing validation. Gating it behind this
93# boolean (created dynamically, like the `tuning:` UI controls) makes that class of mistake
94# structurally impossible: there is no shared schema for the two to be confused under.
95IOHomeAcceptForeignPairingSwitch = home_io_control_ns.class_(
96 "IOHomeAcceptForeignPairingSwitch", switch_component.Switch, cg.Component
97)
98# Hub-level "Flash LR1121 Radio Firmware" button (hub_lr1121_firmware_update.cpp /
99# platform_lr1121_firmware_update_button.h). Same "created dynamically from a home_io_control:
100# sub-block, not a device-bound platform entry" shape as the switch above — there is no
101# `io_device_id` to bind this to, it targets the hub's own radio.
102IOHomeLr1121FirmwareUpdateButton = home_io_control_ns.class_(
103 "IOHomeLr1121FirmwareUpdateButton", button_component.Button, cg.Component
104)
105# Hub-level "Allow LR1121 Bootloader Rewrite (Irreversible)" arming switch
106# (hub_lr1121_firmware_update.cpp / platform_lr1121_bootloader_rewrite_switch.h). Same
107# dynamically-created, hub-bound shape as the two entities above; created only when
108# lr1121_firmware_update.bootloader: is configured (see _create_lr1121_bootloader_update()).
109IOHomeLr1121BootloaderRewriteSwitch = home_io_control_ns.class_(
110 "IOHomeLr1121BootloaderRewriteSwitch", switch_component.Switch, cg.Component
111)
112
113
115 if not config[CONF_ACCEPT_FOREIGN_PAIRING]:
116 return config
117 parent_id = config[CONF_ID]
118 base = parent_id.id if parent_id.id else "home_io_control"
119 config[CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID] = ID(
120 f"{base}_accept_foreign_pairing_switch",
121 is_declaration=True,
122 type=IOHomeAcceptForeignPairingSwitch,
123 )
124 return config
125
126
127def validate_lr1121_firmware_source(value, *, expect_loader=False):
128 """Validate the lr1121_firmware_update `source:` shorthand at schema time.
129
130 Checks the shape (github://owner/repo/path[@ref]) and the image class (transceiver vs.
131 loader vs. modem, by filename -- see lr1121_firmware.validate_image_class()). The network
132 fetch and MD5/image-content verification happen later, in to_code(), where a failure is
133 still a build-time error but one that needs the network anyway.
134 @param expect_loader True for the bootloader sub-block's `source:` (must be a loader image),
135 False for the ordinary transceiver `source:` (must not be one).
136 """
137 value = cv.string_strict(value)
138 try:
139 _, _, path, _ = lr1121_firmware.parse_github_source(value)
140 lr1121_firmware.validate_image_class(path, expect_loader=expect_loader)
142 raise cv.Invalid(str(err)) from err
143 return value
144
145
147 """Validate checksum_md5 as exactly 32 hex characters (MD5)."""
148 value = cv.string_strict(value).lower()
149 if len(value) != 32:
150 raise cv.Invalid("checksum_md5 must be exactly 32 hex characters (MD5)")
151 try:
152 int(value, 16)
153 except ValueError as err:
154 raise cv.Invalid("checksum_md5 must be valid hexadecimal") from err
155 return value
156
157
158# The bootloader sub-block's `source:` must BE a loader image (expect_loader=True) -- the
159# symmetric guard to the outer schema's default expect_loader=False (C8 in the bootloader update
160# ADR 0021): a transceiver image in this slot would erase and overwrite the wrong thing
161# at stage 1a.
162LR1121_BOOTLOADER_SCHEMA = cv.Schema(
163 {
164 cv.Required(CONF_SOURCE): lambda value: validate_lr1121_firmware_source(value, expect_loader=True),
165 cv.Optional(CONF_REF): cv.string_strict,
166 cv.Optional(CONF_CHECKSUM_MD5): validate_checksum_md5,
167 }
168)
169
170LR1121_FIRMWARE_UPDATE_SCHEMA = cv.Schema(
171 {
172 cv.Required(CONF_SOURCE): validate_lr1121_firmware_source,
173 cv.Optional(CONF_REF): cv.string_strict,
174 cv.Optional(CONF_CHECKSUM_MD5): validate_checksum_md5,
175 # target_version exists solely as an escape hatch for a mirrored/renamed image whose
176 # filename carries no version — NOT as a compatibility declaration. There is deliberately
177 # no `requires_bootloader:` key: a user-declared compatibility claim is the wrong shape
178 # for a safety check, since the build can derive it from the filename instead.
179 cv.Optional(CONF_TARGET_VERSION): cv.hex_int,
180 # Presence is the build flag for the bootloader-rewrite feature, exactly as the outer
181 # block's presence already is for the transceiver-update feature -- see ADR 0021.
182 cv.Optional(CONF_LR1121_BOOTLOADER): LR1121_BOOTLOADER_SCHEMA,
183 }
184)
185
186
187def _validate_lr1121_bootloader_block(config):
188 """Implement the build-time compatibility rule for the bootloader: sub-block (ADR 0021).
189
190 Classifies the *outer* source:'s target against LR1121_KNOWN_BOOTLOADER_REQUIREMENTS without
191 any network access (both source: filenames are already schema-validated shapes at this point,
192 so parsing them again here is free). Deliberately three-way, like the runtime compatibility
193 rule: an unrecognised target warns rather than errors, so the feature doesn't rot on Semtech's
194 next release (see lr1121_firmware.classify_bootloader_upgrade_class()'s doc comment).
195 """
196 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
197 if CONF_LR1121_BOOTLOADER not in fw_config:
198 return config
199
200 target_fw = lr1121_firmware.resolve_target_version(fw_config[CONF_SOURCE], fw_config.get(CONF_TARGET_VERSION))
201 upgrade_class = lr1121_firmware.classify_bootloader_upgrade_class(target_fw)
202 if upgrade_class == "hard_error":
203 raise cv.Invalid(
204 f"lr1121_firmware_update.bootloader: is configured, but source: targets firmware 0x{target_fw:04X}, "
205 "which is known to require bootloader 0x2100 -- after the bootloader rewrite this image would be "
206 "unflashable, so this configuration would arm a trap. Point source: at a firmware version requiring "
207 "bootloader 0x2101 (e.g. 0x0104), or remove the bootloader: block."
208 )
209 if upgrade_class == "unknown":
210 _LOGGER.warning(
211 "lr1121_firmware_update.bootloader: is configured, but source: targets an unrecognized firmware "
212 "version (0x%04X); the bootloader-rewrite path will be inert at runtime until this build's "
213 "compatibility table is extended for it (see lr1121_firmware_decisions.h)",
214 target_fw,
215 )
216
217 parent_id = config[CONF_ID]
218 base = parent_id.id if parent_id.id else "home_io_control"
219 config[CONF_LR1121_BOOTLOADER_SWITCH_ID] = ID(
220 f"{base}_lr1121_bootloader_switch",
221 is_declaration=True,
222 type=IOHomeLr1121BootloaderRewriteSwitch,
223 )
224 return config
225
226
227def _validate_lr1121_firmware_update(config):
228 """Gate + inject the button ID for the optional lr1121_firmware_update: block.
229
230 Only runs when the block is present. Rejects configurations that can't reach the LR1121
231 bootloader at all (wrong radio_type, missing busy_pin) or that would silently invert the
232 bootloader-entry level (busy_pin inverted: true — bootloader entry drives BUSY to a physical
233 LOW; see radio_lr1121_firmware_updater.h). Also injects the flash button's companion ID at
234 validation time — see CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID's comment above for why that
235 can't wait until to_code(). The bootloader:-specific checks (C3-C5, and the companion arming
236 switch's ID) live in _validate_lr1121_bootloader_block() above, called at the end of this
237 function so config[CONF_ID] and the reachability checks are already settled.
238 """
239 if CONF_LR1121_FIRMWARE_UPDATE not in config:
240 return config
241 try:
242 lr1121_firmware.validate_bootloader_reachability(
243 radio_type=config[CONF_RADIO_TYPE],
244 has_busy_pin=CONF_BUSY_PIN in config,
245 busy_pin_inverted=config.get(CONF_BUSY_PIN, {}).get(CONF_INVERTED, False),
246 )
248 raise cv.Invalid(str(err)) from err
249
250 parent_id = config[CONF_ID]
251 base = parent_id.id if parent_id.id else "home_io_control"
252 config[CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID] = ID(
253 f"{base}_lr1121_firmware_update_button",
254 is_declaration=True,
255 type=IOHomeLr1121FirmwareUpdateButton,
256 )
257 return _validate_lr1121_bootloader_block(config)
258
259
260PA_PIN_OPTIONS = {
261 "BOOST": 0x80,
262 "RFO": 0x00,
263}
264
265RADIO_TYPE_OPTIONS = {
266 "sx1276": "sx1276",
267 "sx1262": "sx1262",
268 "lr1121": "lr1121",
269}
270
271TCXO_VOLTAGE_OPTIONS = {
272 "1_6V": 0x01,
273 "1_7V": 0x02,
274 "1_8V": 0x03,
275 "2_2V": 0x04,
276 "2_4V": 0x05,
277 "2_7V": 0x06,
278 "3_0V": 0x07,
279 "3_3V": 0x08,
280}
281
282DEVICE_TYPE_OPTIONS = {
283 "unknown": 0x00,
284 "venetian_blind": 0x01,
285 "roller_shutter": 0x02,
286 "awning": 0x03,
287 "window_opener": 0x04,
288 "garage_opener": 0x05,
289 "light": 0x06,
290 "gate_opener": 0x07,
291 "rolling_door_opener": 0x08,
292 "lock": 0x09,
293 "blind": 0x0A,
294 "screen": 0x0B,
295 "dual_shutter": 0x0D,
296 "heating_temperature_interface": 0x0E,
297 "on_off_switch": 0x0F,
298 "horizontal_awning": 0x10,
299 "external_venetian_blind": 0x11,
300 "louvre_blind": 0x12,
301 "curtain_track": 0x13,
302 "intrusion_alarm": 0x17,
303 "swinging_shutter": 0x18,
304}
305
306
307def _resolve_device_type_token(token):
308 """Resolve a lowercase, stripped device-type token (name or raw int/hex string) to 0-255.
309
310 Single source of truth for the "named value from DEVICE_TYPE_OPTIONS, else raw integer"
311 acceptance rule shared by validate_device_type() (io_device_type) and
312 validate_linked_remote_entry() (the class:<device_type> linked-remotes form) — both accept
313 the exact same set of device-type spellings, so the lookup lives here once.
314 @raises ValueError if token is neither a known name nor a parseable integer.
315 @raises cv.Invalid if token parses as an integer but is out of range 0-255.
316 """
317 if token in DEVICE_TYPE_OPTIONS:
318 return DEVICE_TYPE_OPTIONS[token]
319 return cv.int_range(min=0, max=0xFF)(int(token, 0))
320
321
322def validate_device_type(value):
323 """Validate io_device_type as a named string or integer 0-255."""
324 if isinstance(value, int):
325 return cv.int_range(min=0, max=0xFF)(value)
326
327 if isinstance(value, str):
328 normalized = cv.string_strict(value).strip().lower()
329 try:
330 return _resolve_device_type_token(normalized)
331 except ValueError as err:
332 raise cv.Invalid(
333 "Device type must be a known name or an integer in the range 0..255 (for example 0x11)"
334 ) from err
335
336 raise cv.Invalid(
337 "Device type must be a known name or an integer in the range 0..255"
338 )
339
340
341def device_type_expression(value):
342 """Generate a C++ static_cast expression for a validated device type."""
343 return cg.RawExpression(
344 f"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
345 )
346
347
348def validate_node_id(value):
349 """Validate node_id as exactly 6 hex characters (3 bytes)."""
350 value = cv.string_strict(value).upper()
351 if len(value) != 6:
352 raise cv.Invalid("Node ID must be exactly 6 hex characters (3 bytes)")
353 try:
354 int(value, 16)
355 except ValueError:
356 raise cv.Invalid("Node ID must be valid hexadecimal")
357 return value
358
359
360def validate_system_key(value):
361 """Validate system_key as exactly 32 hex characters (16 bytes)."""
362 value = cv.string_strict(value).upper()
363 if len(value) != 32:
364 raise cv.Invalid("System key must be exactly 32 hex characters (16 bytes)")
365 try:
366 int(value, 16)
367 except ValueError:
368 raise cv.Invalid("System key must be valid hexadecimal")
369 return value
370
371
372def validate_device_id(value):
373 """Validate io_device_id as exactly 6 hex characters (3 bytes)."""
374 value = cv.string_strict(value).upper()
375 if len(value) != 6:
376 raise cv.Invalid("Device ID must be exactly 6 hex characters (3 bytes)")
377 try:
378 int(value, 16)
379 except ValueError:
380 raise cv.Invalid("Device ID must be valid hexadecimal")
381 return value
382
383
384def validate_linked_remote_entry(value):
385 """Validate a linked_remotes entry: either a device ID or 'class:<device_type>'.
386
387 The class form matches how 1W remotes address a typed broadcast (e.g. "all awnings")
388 rather than a single node, so one entry can cover many same-type devices without
389 enumerating each one. Shares _resolve_device_type_token() with validate_device_type()
390 so a type without a named YAML alias yet (e.g. discovered via pairing) can still be
391 class-linked. Normalized to 'class:0x<HH>' (uppercase hex) so wire_device_binding() can
392 parse the type directly without a second DEVICE_TYPE_OPTIONS lookup; bare device IDs are
393 validated exactly as before and behave identically.
394 """
395 if isinstance(value, str) and value.lower().startswith("class:"):
396 type_token = value.split(":", 1)[1].strip().lower()
397 try:
398 type_value = _resolve_device_type_token(type_token)
399 except ValueError as err:
400 raise cv.Invalid(
401 f"Unknown device class '{type_token}' in linked_remotes; expected one of: "
402 + ", ".join(sorted(DEVICE_TYPE_OPTIONS))
403 + ", or a raw integer such as 0x14"
404 ) from err
405 return f"class:0x{type_value:02X}"
406 return validate_device_id(value)
407
408
409def validate_status_poll_interval(value):
410 """Validate status_poll_interval is at least MIN_STATUS_POLL_INTERVAL_MS."""
411 value = cv.positive_time_period_milliseconds(value)
412 if value.total_milliseconds < MIN_STATUS_POLL_INTERVAL_MS:
413 raise cv.Invalid(
414 f"status_poll_interval must be at least {MIN_STATUS_POLL_INTERVAL_MS}ms"
415 )
416 return value
417
418
419CONFIG_SCHEMA = cv.All(
420 cv.Schema(
421 {
422 cv.GenerateID(): cv.declare_id(IOHomeControlComponent),
423 cv.Required(CONF_RST_PIN): pins.internal_gpio_output_pin_schema,
424 cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema,
425 cv.Optional(CONF_DIO4_PIN): pins.internal_gpio_input_pin_schema,
426 # The chip's IRQ line: SX1262's DIO1, or LR1121's DIO9
427 cv.Optional(CONF_DIO1_PIN): pins.internal_gpio_input_pin_schema,
428 cv.Optional(CONF_BUSY_PIN): pins.internal_gpio_input_pin_schema,
429 cv.Required(CONF_NODE_ID): validate_node_id,
430 cv.Required(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
431 cv.Optional(CONF_TX_POWER, default=17): cv.int_range(min=0, max=22),
432 cv.Optional(CONF_PA_PIN, default="BOOST"): cv.enum(
433 PA_PIN_OPTIONS, upper=True
434 ),
435 cv.Required(CONF_RADIO_TYPE): cv.enum(RADIO_TYPE_OPTIONS, lower=True),
436 cv.Optional(CONF_FEM_EN_PIN): pins.internal_gpio_output_pin_schema,
437 cv.Optional(CONF_VFEM_PIN): pins.internal_gpio_output_pin_schema,
438 cv.Optional(CONF_FEM_PA_PIN): pins.internal_gpio_output_pin_schema,
439 cv.Optional(CONF_TCXO_VOLTAGE, default="1_8V"): cv.enum(
440 TCXO_VOLTAGE_OPTIONS, upper=True
441 ),
442 cv.Optional(CONF_EXPOSED_SENDERS, default=[]): cv.ensure_list(
443 validate_device_id
444 ),
445 cv.Optional(CONF_ACCEPT_FOREIGN_PAIRING, default=False): cv.boolean,
446 cv.Optional(CONF_LR1121_FIRMWARE_UPDATE): LR1121_FIRMWARE_UPDATE_SCHEMA,
447 cv.Optional(tuning_module.CONF_TUNING): tuning_module.TUNING_CONFIG_SCHEMA,
448 }
449 )
450 .extend(cv.COMPONENT_SCHEMA)
451 .extend(spi.spi_device_schema(True, 8e6, "mode0")),
452 _inject_accept_foreign_pairing_switch_id,
453 _validate_lr1121_firmware_update,
454)
455
456
457async def to_code(config):
458 # Hub-level management actions and result events are compiled behind native API
459 # feature flags. Home IO Control enables the required compile-time switches here
460 # so users only need a normal `api:` block in YAML.
461 # ESPHome 2026.x additionally gates user-defined actions behind
462 # USE_API_USER_DEFINED_ACTIONS.
463 cg.add_define("USE_API_USER_DEFINED_ACTIONS")
464 cg.add_define("USE_API_CUSTOM_SERVICES")
465 cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
466
467 var = cg.new_Pvariable(config[CONF_ID])
468 await cg.register_component(var, config)
469 await spi.register_spi_device(var, config)
470
471 rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN])
472 cg.add(var.set_rst_pin(rst_pin))
473
474 if CONF_DIO0_PIN in config:
475 dio0_pin = await cg.gpio_pin_expression(config[CONF_DIO0_PIN])
476 cg.add(var.set_dio0_pin(dio0_pin))
477
478 if CONF_DIO4_PIN in config:
479 dio4_pin = await cg.gpio_pin_expression(config[CONF_DIO4_PIN])
480 cg.add(var.set_dio4_pin(dio4_pin))
481
482 if CONF_DIO1_PIN in config:
483 dio1_pin = await cg.gpio_pin_expression(config[CONF_DIO1_PIN])
484 cg.add(var.set_dio1_pin(dio1_pin))
485
486 if CONF_BUSY_PIN in config:
487 busy_pin = await cg.gpio_pin_expression(config[CONF_BUSY_PIN])
488 cg.add(var.set_busy_pin(busy_pin))
489
490 if CONF_FEM_EN_PIN in config:
491 fem_en_pin = await cg.gpio_pin_expression(config[CONF_FEM_EN_PIN])
492 cg.add(var.set_fem_en_pin(fem_en_pin))
493
494 if CONF_VFEM_PIN in config:
495 vfem_pin = await cg.gpio_pin_expression(config[CONF_VFEM_PIN])
496 cg.add(var.set_vfem_pin(vfem_pin))
497
498 if CONF_FEM_PA_PIN in config:
499 fem_pa_pin = await cg.gpio_pin_expression(config[CONF_FEM_PA_PIN])
500 cg.add(var.set_fem_pa_pin(fem_pa_pin))
501
502 cg.add(var.set_node_id(config[CONF_NODE_ID]))
503 cg.add(var.set_system_key(config[CONF_SYSTEM_KEY]))
504 cg.add(var.set_tx_power(config[CONF_TX_POWER]))
505 cg.add(var.set_pa_pin(config[CONF_PA_PIN]))
506
507 cg.add(var.set_radio_type(config[CONF_RADIO_TYPE]))
508
509 cg.add(var.set_tcxo_voltage(config[CONF_TCXO_VOLTAGE]))
510
511 for sender_id in config[CONF_EXPOSED_SENDERS]:
512 cg.add(var.add_exposed_sender(sender_id))
513
514 if config[CONF_ACCEPT_FOREIGN_PAIRING]:
515 await _create_accept_foreign_pairing_switch(config, var)
516
517 if CONF_LR1121_FIRMWARE_UPDATE in config:
518 await _create_lr1121_firmware_update(config, var)
519
520 if tuning_module.CONF_TUNING in config:
521 await tuning_module.to_code(config[tuning_module.CONF_TUNING], var)
522
523
524async def _create_accept_foreign_pairing_switch(config, var):
525 """Create the hub-level "Accept Foreign Pairing (Key Extraction)" switch.
526
527 Mirrors tuning.py's _create_number()/_create_select(): normalize a bare {id, name} dict
528 through switch_schema()+COMPONENT_SCHEMA so it carries the entity/component defaults
529 register_switch()/register_component() require, matching the neighboring pattern rather than
530 hand-assembling a config dict shape of its own.
531 """
532 entity_config = switch_component.switch_schema(
533 IOHomeAcceptForeignPairingSwitch,
534 default_restore_mode="ALWAYS_OFF", # never auto-arm after a reboot
535 entity_category=ENTITY_CATEGORY_CONFIG,
536 ).extend(cv.COMPONENT_SCHEMA)(
537 {
538 CONF_ID: config[CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID],
539 CONF_NAME: "Accept Foreign Pairing (Key Extraction)",
540 }
541 )
542 entity = await switch_component.new_switch(entity_config)
543 await cg.register_component(entity, entity_config)
544 cg.add(entity.set_parent(var))
545
546
547def _cached_http_fetch(cache_dir):
548 """Build a `fetch(url, expected_hash=None) -> bytes` callable for
549 lr1121_firmware.fetch_and_verify(), backed by an on-disk cache so repeat and offline builds
550 don't re-download the same source.
551
552 The cache key incorporates `expected_hash` (the MD5 fetch_and_verify() already resolved from
553 the `.md5` sidecar or `checksum_md5:` before calling this for the `.bin`) rather than being
554 `sha256(url)` alone. With the default `ref: HEAD` the URL never changes, so a plain
555 url-only key means a corrupt/truncated download poisons the cache permanently -- no config
556 change can ever invalidate it, since nothing about the request changes on retry. Folding the
557 expected hash in means correcting a wrong `checksum_md5:` (or a fixed upstream sidecar) misses
558 the poisoned entry and forces a fresh download. The `.md5` sidecar fetch itself has no
559 expected_hash to key on (chicken-and-egg -- it's what supplies one for the .bin) and is cached
560 under the URL alone; a corrupted sidecar is a much smaller/rarer risk than a corrupted 64+ KB
561 binary, and the cache directory below is a manual escape hatch either way.
562
563 Data that fails its own hash check is deliberately never written to the cache (verify-before-store):
564 a transient network corruption then simply retries cleanly on the next build, with no
565 config change needed at all.
566 """
567 cache_dir.mkdir(parents=True, exist_ok=True)
568
569 def fetch(url, expected_hash=None):
570 cache_key = hashlib.sha256(f"{url}|{expected_hash or ''}".encode("utf-8")).hexdigest()
571 cache_path = cache_dir / cache_key
572 if cache_path.exists():
573 return cache_path.read_bytes()
574 try:
575 with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
576 data = response.read()
577 except urllib.error.HTTPError as err:
578 if err.code == 404:
580 raise lr1121_firmware.Lr1121FirmwareError(f"HTTP {err.code} fetching {url}") from err
581 except urllib.error.URLError as err:
582 raise lr1121_firmware.Lr1121FirmwareError(f"Failed to fetch {url}: {err}") from err
583 if expected_hash is None or hashlib.md5(data).hexdigest() == expected_hash: # noqa: S324
584 cache_path.write_bytes(data)
585 return data
586
587 return fetch
588
589
590def _render_lr1121_image_header(image, array_name, words_name, version_name):
591 """Render a verified firmware/loader image as a C++ header.
592
593 Each raw 4-byte chunk of the `.bin` is exactly one big-endian word as Semtech's own image
594 format already lays it out, so this only has to slice and format, not transform, the bytes.
595 `inline const` (not `constexpr`) for the array: it is never used in a constant expression, so
596 forcing constant-evaluation of up to ~61k elements would only cost compile time; `const` at
597 namespace scope still lands in `.rodata` (flash) on ESP32, not RAM. Shared by
598 _render_lr1121_firmware_header() (the transceiver image) and the bootloader loader image --
599 same shape, different symbol names so both headers can be included from the same translation
600 unit without colliding.
601 """
602 words = [f"0x{int.from_bytes(image.data[i : i + 4], 'big'):08X}" for i in range(0, len(image.data), 4)]
603 words_per_line = 8
604 body_lines = [
605 " " + ", ".join(words[i : i + words_per_line]) + "," for i in range(0, len(words), words_per_line)
606 ]
607 return "\n".join(
608 [
609 "#pragma once",
610 "// Auto-generated by the home_io_control lr1121_firmware_update build step. Do not edit.",
611 "#include <cstddef>",
612 "#include <cstdint>",
613 "",
614 "namespace esphome {",
615 "namespace home_io_control {",
616 "",
617 f"inline const uint32_t {array_name}[] = {{",
618 *body_lines,
619 "};",
620 f"inline constexpr size_t {words_name} = {len(words)};",
621 f"inline constexpr uint16_t {version_name} = 0x{image.version:04X};",
622 "",
623 "} // namespace home_io_control",
624 "} // namespace esphome",
625 "",
626 ]
627 )
628
629
630def _render_lr1121_firmware_header(image):
631 """Render the verified transceiver firmware image as a C++ header."""
632 return _render_lr1121_image_header(
633 image, "LR1121_FIRMWARE_UPDATE_IMAGE", "LR1121_FIRMWARE_UPDATE_IMAGE_WORDS", "LR1121_FIRMWARE_UPDATE_TARGET_VERSION"
634 )
635
636
637def _render_lr1121_bootloader_loader_header(image):
638 """Render the verified bootloader *loader* image as a C++ header (ADR 0021)."""
639 return _render_lr1121_image_header(
640 image, "LR1121_BOOTLOADER_LOADER_IMAGE", "LR1121_BOOTLOADER_LOADER_IMAGE_WORDS", "LR1121_BOOTLOADER_LOADER_FW"
641 )
642
643
644async def _create_lr1121_firmware_update(config, var):
645 """Fetch/verify the configured firmware image, generate its header, set the build flag that
646 gates the whole feature, and create the "Flash LR1121 Radio Firmware" button.
647
648 The block's mere presence in YAML is the build flag (ADR 0020) — there is no
649 separate enable switch, so entering/leaving flash mode is a recompile + OTA each way.
650 """
651 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
652 cache_dir = CORE.data_dir / "lr1121_firmware_cache"
653 try:
654 image = lr1121_firmware.fetch_and_verify(
655 source=fw_config[CONF_SOURCE],
656 ref=fw_config.get(CONF_REF),
657 checksum_md5=fw_config.get(CONF_CHECKSUM_MD5),
658 target_version=fw_config.get(CONF_TARGET_VERSION),
659 fetch=_cached_http_fetch(cache_dir),
660 )
662 raise cv.Invalid(f"lr1121_firmware_update: {err}") from err
663
664 header_path = CORE.relative_src_path("lr1121_firmware_update_image.h")
665 write_file_if_changed(header_path, _render_lr1121_firmware_header(image))
666
667 cg.add_define("IOHOME_LR1121_FIRMWARE_UPDATE")
668
669 if CONF_LR1121_BOOTLOADER in fw_config:
670 await _create_lr1121_bootloader_update(fw_config[CONF_LR1121_BOOTLOADER], config, var, cache_dir)
671
672 entity_config = button_component.button_schema(
673 IOHomeLr1121FirmwareUpdateButton,
674 entity_category=ENTITY_CATEGORY_CONFIG,
675 ).extend(cv.COMPONENT_SCHEMA)(
676 {
677 CONF_ID: config[CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID],
678 CONF_NAME: "Flash LR1121 Radio Firmware",
679 }
680 )
681 entity = await button_component.new_button(entity_config)
682 await cg.register_component(entity, entity_config)
683 cg.add(entity.set_parent(var))
684
685
686async def _create_lr1121_bootloader_update(bootloader_config, config, var, cache_dir):
687 """Fetch/verify the configured loader image, generate its header, set the build flag that
688 gates the bootloader-rewrite feature, and create the arming switch.
689
690 Mirrors _create_lr1121_firmware_update() above -- same "block's presence is the build flag"
691 shape, one level down (ADR 0021). `target_version` is not passed to
692 fetch_and_verify(): the loader is not a "target" the way the transceiver image is, its version
693 is only ever compared for *equality* against the currently-running bootloader (Semtech's
694 rule), so there is nothing to override.
695 """
696 try:
697 loader_image = lr1121_firmware.fetch_and_verify(
698 source=bootloader_config[CONF_SOURCE],
699 ref=bootloader_config.get(CONF_REF),
700 checksum_md5=bootloader_config.get(CONF_CHECKSUM_MD5),
701 target_version=None,
702 fetch=_cached_http_fetch(cache_dir),
703 )
705 raise cv.Invalid(f"lr1121_firmware_update.bootloader: {err}") from err
706
707 header_path = CORE.relative_src_path("lr1121_bootloader_loader_image.h")
708 write_file_if_changed(header_path, _render_lr1121_bootloader_loader_header(loader_image))
709
710 cg.add_define("IOHOME_LR1121_BOOTLOADER_UPDATE")
711
712 entity_config = switch_component.switch_schema(
713 IOHomeLr1121BootloaderRewriteSwitch,
714 default_restore_mode="ALWAYS_OFF", # never auto-arm after a reboot -- ADR 0021
715 entity_category=ENTITY_CATEGORY_CONFIG,
716 ).extend(cv.COMPONENT_SCHEMA)(
717 {
718 CONF_ID: config[CONF_LR1121_BOOTLOADER_SWITCH_ID],
719 CONF_NAME: "Allow LR1121 Bootloader Rewrite (Irreversible)",
720 # Deliberately NOT disabled_by_default. It reads like the right call for an irreversible
721 # control, but in Home Assistant that disables the entity in the registry: it cannot be
722 # toggled until the user finds it and enables it by hand, which makes the documented
723 # procedure ("turn the switch on, press the button") simply not work. It also defeats
724 # ADR 0021's reason for choosing a switch over an invisible confirmation window -- that
725 # the armed state is answerable by looking -- since a disabled entity is not shown at
726 # all. entity_category=config is the right amount of out-of-the-way: it files the switch
727 # under Configuration rather than among the primary controls, and it stays usable.
728 # The real gating is elsewhere and unaffected: the bootloader: block must be in YAML and
729 # the firmware rebuilt, and the switch is off on every boot (ALWAYS_OFF).
730 }
731 )
732 entity = await switch_component.new_switch(entity_config)
733 await cg.register_component(entity, entity_config)
734 cg.add(entity.set_parent(var))
validate_checksum_md5(value)
Definition __init__.py:146
_inject_accept_foreign_pairing_switch_id(config)
Definition __init__.py:114
validate_lr1121_firmware_source(value, *, expect_loader=False)
Definition __init__.py:127