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
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 (
37 ENTITY_CATEGORY_CONFIG,
38 ENTITY_CATEGORY_DIAGNOSTIC,
40from esphome.core
import CORE, ID
41from esphome.helpers
import write_file_if_changed
43from .
import lr1121_firmware
44from .
import tuning
as tuning_module
46_LOGGER = logging.getLogger(__name__)
48DEPENDENCIES = [
"api",
"spi"]
49AUTO_LOAD = [
"button",
"climate",
"cover",
"light",
"lock",
"number",
"select",
"sensor",
"switch",
"text_sensor"]
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"
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"
74CONF_NODE_ID_DERIVED =
"_node_id_derived"
75CONF_MANUFACTURER =
"manufacturer"
78CONF_IO_DEVICE_TYPE =
"io_device_type"
79CONF_INITIAL_SEQUENCE =
"initial_sequence"
80CONF_COMMANDS =
"commands"
84CONF_EXECUTE_ACEI =
"execute_acei"
85CONF_EXECUTE_BROADCAST =
"execute_broadcast"
89CONF_ENROLLMENT =
"enrollment"
93CONF_ENROLLMENT_WITH_MAC =
"enrollment_with_mac"
97CONF_ENROLLMENT_CLASSES =
"enrollment_classes"
100CONF_BUTTON_IDS =
"button_ids"
101CONF_LAST_COMMAND_SENSOR_ID =
"last_command_sensor_id"
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
117CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID =
"_accept_foreign_pairing_switch_id"
120CONF_RECOVER_ONEWAY_KEY_SWITCH_ID =
"_recover_oneway_key_switch_id"
123CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID =
"_lr1121_firmware_update_button_id"
126CONF_SCAN_PAIRED_DEVICES_BUTTON_ID =
"_scan_paired_devices_button_id"
130CONF_LR1121_BOOTLOADER_SWITCH_ID =
"_lr1121_bootloader_switch_id"
132home_io_control_ns = cg.esphome_ns.namespace(
"home_io_control")
133IOHomeControlComponent = home_io_control_ns.class_(
134 "IOHomeControlComponent", cg.Component, spi.SPIDevice
143IOHomeAcceptForeignPairingSwitch = home_io_control_ns.class_(
144 "IOHomeAcceptForeignPairingSwitch", switch_component.Switch, cg.Component
150IOHomeRecoverOneWayKeySwitch = home_io_control_ns.class_(
151 "IOHomeRecoverOneWayKeySwitch", switch_component.Switch, cg.Component
157IOHomeLr1121FirmwareUpdateButton = home_io_control_ns.class_(
158 "IOHomeLr1121FirmwareUpdateButton", button_component.Button, cg.Component
164IOHomeScanPairedDevicesButton = home_io_control_ns.class_(
165 "IOHomeScanPairedDevicesButton", button_component.Button, cg.Component
170IOHomeOneWayCommandButton = home_io_control_ns.class_(
171 "IOHomeOneWayCommandButton", button_component.Button, cg.Component
173IOHomeOneWayLastCommandTextSensor = home_io_control_ns.class_(
174 "IOHomeOneWayLastCommandTextSensor", text_sensor_component.TextSensor, cg.Component
179IOHomeOneWayEnrollButton = home_io_control_ns.class_(
180 "IOHomeOneWayEnrollButton", button_component.Button, cg.Component
182OneWayButtonAction = home_io_control_ns.enum(
"OneWayButtonAction", is_class=
True)
187 "open": OneWayButtonAction.OPEN,
188 "close": OneWayButtonAction.CLOSE,
189 "stop": OneWayButtonAction.STOP,
190 "vent": OneWayButtonAction.VENT,
191 "favorite": OneWayButtonAction.FAVORITE,
197IOHomeLr1121BootloaderRewriteSwitch = home_io_control_ns.class_(
198 "IOHomeLr1121BootloaderRewriteSwitch", switch_component.Switch, cg.Component
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().
209 if not config[flag_key]:
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)
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,
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,
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,
248 """Validate the lr1121_firmware_update `source:` shorthand at schema time.
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).
257 value = cv.string_strict(value)
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
267 """Validate checksum_md5 as exactly 32 hex characters (MD5)."""
268 value = cv.string_strict(value).lower()
270 raise cv.Invalid(
"checksum_md5 must be exactly 32 hex characters (MD5)")
273 except ValueError
as err:
274 raise cv.Invalid(
"checksum_md5 must be valid hexadecimal")
from err
282LR1121_BOOTLOADER_SCHEMA = cv.Schema(
285 cv.Optional(CONF_REF): cv.string_strict,
286 cv.Optional(CONF_CHECKSUM_MD5): validate_checksum_md5,
290LR1121_FIRMWARE_UPDATE_SCHEMA = cv.Schema(
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,
299 cv.Optional(CONF_TARGET_VERSION): cv.hex_int,
302 cv.Optional(CONF_LR1121_BOOTLOADER): LR1121_BOOTLOADER_SCHEMA,
307def _validate_lr1121_bootloader_block(config):
308 """Implement the build-time compatibility rule for the bootloader: sub-block (ADR 0021).
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).
316 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
317 if CONF_LR1121_BOOTLOADER
not in fw_config:
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":
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."
329 if upgrade_class ==
"unknown":
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)",
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",
342 type=IOHomeLr1121BootloaderRewriteSwitch,
347def _validate_lr1121_firmware_update(config):
348 """Gate + inject the button ID for the optional lr1121_firmware_update: block.
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.
359 if CONF_LR1121_FIRMWARE_UPDATE
not in config:
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),
368 raise cv.Invalid(str(err))
from err
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",
375 type=IOHomeLr1121FirmwareUpdateButton,
377 return _validate_lr1121_bootloader_block(config)
385RADIO_TYPE_OPTIONS = {
391TCXO_VOLTAGE_OPTIONS = {
402DEVICE_TYPE_OPTIONS = {
404 "venetian_blind": 0x01,
405 "roller_shutter": 0x02,
407 "window_opener": 0x04,
408 "garage_opener": 0x05,
411 "rolling_door_opener": 0x08,
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,
432MANUFACTURER_OPTIONS = {
439 "window_master": 0x07,
444 "atlantic_group": 0x0C,
450_ONEWAY_WIRE_PROFILE_MANUFACTURERS = {
451 MANUFACTURER_OPTIONS[
"somfy"],
452 MANUFACTURER_OPTIONS[
"velux"],
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`.
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.
468 return options[token]
469 return cv.int_range(min=0, max=max_value)(int(token, 0))
472def _resolve_device_type_token(token):
473 """Resolve a lowercase, stripped device-type token (name or raw int/hex string) to 0-255.
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.
482 return _resolve_named_or_raw_token(token, DEVICE_TYPE_OPTIONS)
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)
490 if isinstance(value, str):
491 normalized = cv.string_strict(value).strip().lower()
493 return _resolve_device_type_token(normalized)
494 except ValueError
as err:
496 "Device type must be a known name or an integer in the range 0..255 (for example 0x11)"
500 "Device type must be a known name or an integer in the range 0..255"
504def validate_manufacturer(value):
505 """Validate manufacturer as a named string (MANUFACTURER_OPTIONS) or integer 0-255.
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.
511 if isinstance(value, int):
512 return cv.int_range(min=0, max=0xFF)(value)
514 if isinstance(value, str):
515 normalized = cv.string_strict(value).strip().lower()
517 return _resolve_named_or_raw_token(normalized, MANUFACTURER_OPTIONS)
518 except ValueError
as err:
520 "manufacturer must be a known name or an integer in the range 0..255 (for example 0x02)"
523 raise cv.Invalid(
"manufacturer must be a known name or an integer in the range 0..255")
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})"
533def validate_node_id(value):
534 """Validate node_id as exactly 6 hex characters (3 bytes)."""
535 value = cv.string_strict(value).upper()
537 raise cv.Invalid(
"Node ID must be exactly 6 hex characters (3 bytes)")
541 raise cv.Invalid(
"Node ID must be valid hexadecimal")
545def validate_system_key(value):
546 """Validate system_key as exactly 32 hex characters (16 bytes)."""
547 value = cv.string_strict(value).upper()
549 raise cv.Invalid(
"System key must be exactly 32 hex characters (16 bytes)")
553 raise cv.Invalid(
"System key must be valid hexadecimal")
567def _no_duplicate_enrollment_classes(value):
568 """Reject a repeated class in `enrollment_classes:` -- each just retransmits the same 0x30."""
573 (n
for n, v
in DEVICE_TYPE_OPTIONS.items()
if v == entry), hex(entry)
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"
583ONEWAY_CONTROLLER_SCHEMA = cv.Schema(
585 cv.Required(CONF_ID): cv.string_strict,
588 cv.Optional(CONF_NODE_ID): validate_node_id,
592 cv.Optional(CONF_SYSTEM_KEY): cv.sensitive(validate_system_key),
596 cv.Optional(CONF_MANUFACTURER): validate_manufacturer,
597 cv.Required(CONF_IO_DEVICE_TYPE): validate_device_type,
603 cv.Optional(CONF_INITIAL_SEQUENCE, default=0): cv.int_range(min=0, max=0xFFFF),
607 cv.Optional(CONF_COMMANDS, default=[]): cv.ensure_list(
608 cv.one_of(*ONEWAY_COMMANDS, lower=
True)
612 cv.Optional(CONF_ENROLLMENT, default=
False): cv.boolean,
619 cv.Optional(CONF_ENROLLMENT_WITH_MAC, default=
False): cv.boolean,
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,
637 cv.Optional(CONF_EXECUTE_ACEI): cv.All(cv.hex_int, cv.int_range(min=1, max=0xFF)),
641 cv.Optional(CONF_EXECUTE_BROADCAST, default=
"typed"): cv.one_of(
642 "typed",
"all", lower=
True
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.
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.
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
663 Uses BLAKE2b rather than Python's hash(), which is salted per-process and would produce a
664 different address on every compile.
666 digest = hashlib.blake2b(
667 f
"{hub_node_id}:{identity_id}".encode(), digest_size=3
669 return f
"{digest[0]:02X}{digest[1]:02X}{digest[2]:02X}"
672def _hex_byte_array(hex_string):
673 """Render a hex string as a C++ brace-initialiser list of bytes."""
675 f
"0x{hex_string[i : i + 2]}" for i
in range(0, len(hex_string), 2)
677 return f
"{{{values}}}"
680def _enrollment_classes_initialiser(identity):
681 """Render `enrollment_classes:` as a 3-element std::array<DeviceType,3> brace-initialiser.
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".
686 padded = (list(identity.get(CONF_ENROLLMENT_CLASSES, [])) + [0, 0, 0])[:3]
688 f
"static_cast<esphome::home_io_control::DeviceType>(0x{value:02X})"
691 return f
"{{{entries}}}"
694def oneway_controller_expression(identity, hub_node_id):
695 """Generate the C++ OneWayControllerIdentity initialiser for one configured identity.
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.
700 derived = identity.get(CONF_NODE_ID_DERIVED,
False)
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)}",
720 "home_io_control: oneway_controllers '%s' node_id derived from hub %s -> %s",
723 identity[CONF_NODE_ID],
725 return cg.RawExpression(
726 f
"esphome::home_io_control::OneWayControllerIdentity{{{fields}}}"
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.
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
739 if node_id
not in seen_node_ids:
741 owner = seen_node_ids[node_id]
743 " (this address was derived; set node_id: explicitly to resolve the clash)"
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."
754def _validate_oneway_controllers(config):
755 """Resolve per-identity defaults and reject address/handle collisions at compile time.
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.
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
768 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
772 hub_node_id = config[CONF_NODE_ID]
776 seen_node_ids = {hub_node_id:
"the hub's own node_id"}
778 for identity
in identities:
779 identity_id = identity[CONF_ID]
780 if identity_id
in seen_ids:
782 f
"Duplicate oneway_controllers id '{identity_id}' — each identity needs its own handle"
784 seen_ids.add(identity_id)
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
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}'"
796 if CONF_SYSTEM_KEY
not in identity:
797 identity[CONF_SYSTEM_KEY] = config[CONF_SYSTEM_KEY]
803 if identity[CONF_ENROLLMENT]
and CONF_MANUFACTURER
not in identity:
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."
812 manufacturer_explicit = CONF_MANUFACTURER
in identity
813 if CONF_MANUFACTURER
not in identity:
814 identity[CONF_MANUFACTURER] = 0
822 identity_can_transmit = bool(identity[CONF_COMMANDS])
or identity[CONF_ENROLLMENT]
824 manufacturer_explicit
825 and identity[CONF_MANUFACTURER]
not in _ONEWAY_WIRE_PROFILE_MANUFACTURERS
826 and identity_can_transmit
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.",
833 identity[CONF_MANUFACTURER],
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]
846 DEVICE_TYPE_OPTIONS[
"screen"],
847 DEVICE_TYPE_OPTIONS[
"blind"],
848 DEVICE_TYPE_OPTIONS[
"venetian_blind"],
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.",
859 for name, value
in DEVICE_TYPE_OPTIONS.items()
860 if value == identity[CONF_IO_DEVICE_TYPE]
867 if CONF_ENROLLMENT_CLASSES
in identity
and (
868 identity[CONF_MANUFACTURER] != MANUFACTURER_OPTIONS[
"velux"]
869 or not identity[CONF_ENROLLMENT]
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.",
882 identity[CONF_BUTTON_IDS] = {
884 f
"{identity_id}_{command}",
886 type=IOHomeOneWayCommandButton,
888 for command
in identity[CONF_COMMANDS]
890 identity[CONF_LAST_COMMAND_SENSOR_ID] = ID(
891 f
"{identity_id}_last_1w_command",
893 type=IOHomeOneWayLastCommandTextSensor,
895 if identity[CONF_ENROLLMENT]:
896 identity[CONF_ENROLL_BUTTON_ID] = ID(
897 f
"{identity_id}_enroll",
899 type=IOHomeOneWayEnrollButton,
908_DEVICE_BOUND_DOMAINS = (
"cover",
"light",
"lock",
"switch")
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.
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.
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.
927 from .platform_common
import CONF_IO_DEVICE_ID, CONF_LINKED_REMOTES
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":
934 owner_name = entry.get(CONF_NAME)
or entry.get(CONF_ID)
or "<unnamed>"
935 device_id = entry.get(CONF_IO_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:"):
941 addresses[remote] = f
"{domain} '{owner_name}' linked_remotes"
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
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.
957 identities = config.get(CONF_ONEWAY_CONTROLLERS, [])
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)
969FINAL_VALIDATE_SCHEMA = _final_validate_oneway_controller_addresses
972def validate_device_id(value):
973 """Validate io_device_id as exactly 6 hex characters (3 bytes)."""
974 value = cv.string_strict(value).upper()
976 raise cv.Invalid(
"Device ID must be exactly 6 hex characters (3 bytes)")
980 raise cv.Invalid(
"Device ID must be valid hexadecimal")
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.
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.
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.
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.
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
1012def validate_linked_remote_entry(value):
1013 """Validate a linked_remotes entry: either a device ID or 'class:<device_type>'.
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.
1023 if isinstance(value, str)
and value.lower().startswith(
"class:"):
1024 type_token = value.split(
":", 1)[1].strip().lower()
1026 type_value = _resolve_device_type_token(type_token)
1027 except ValueError
as err:
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"
1033 return f
"class:0x{type_value:02X}"
1034 return validate_device_id(value)
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:
1042 f
"status_poll_interval must be at least {MIN_STATUS_POLL_INTERVAL_MS}ms"
1047CONFIG_SCHEMA = cv.All(
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,
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
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
1070 cv.Optional(CONF_EXPOSED_SENDERS, default=[]): cv.ensure_list(
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
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,
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,
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")
1104 var = cg.new_Pvariable(config[CONF_ID])
1105 await cg.register_component(var, config)
1106 await spi.register_spi_device(var, config)
1108 rst_pin = await cg.gpio_pin_expression(config[CONF_RST_PIN])
1109 cg.add(var.set_rst_pin(rst_pin))
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))
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))
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))
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))
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))
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))
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))
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]))
1144 cg.add(var.set_radio_type(config[CONF_RADIO_TYPE]))
1146 cg.add(var.set_tcxo_voltage(config[CONF_TCXO_VOLTAGE]))
1148 for sender_id
in config[CONF_EXPOSED_SENDERS]:
1149 cg.add(var.add_exposed_sender(sender_id))
1151 for identity
in config[CONF_ONEWAY_CONTROLLERS]:
1153 var.add_oneway_controller(
1154 oneway_controller_expression(identity, config[CONF_NODE_ID])
1159 if config[CONF_ACCEPT_FOREIGN_PAIRING]:
1163 cls=IOHomeAcceptForeignPairingSwitch,
1164 id_key=CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID,
1165 name=
"Recover System Key",
1168 if config[CONF_RECOVER_ONEWAY_KEY]:
1172 cls=IOHomeRecoverOneWayKeySwitch,
1173 id_key=CONF_RECOVER_ONEWAY_KEY_SWITCH_ID,
1174 name=
"Recover 1W Controller Key",
1177 if config[CONF_SCAN_PAIRED_DEVICES_BUTTON]:
1180 cg.add(var.set_diagnostic_probes_enabled(config[CONF_DIAGNOSTIC_PROBES]))
1182 if CONF_LR1121_FIRMWARE_UPDATE
in config:
1185 if tuning_module.CONF_TUNING
in config:
1186 await tuning_module.to_code(config[tuning_module.CONF_TUNING], var)
1190 """Create one identity's command buttons and its "Last 1W Command" diagnostic sensor.
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.
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.
1200 friendly_identity = identity[CONF_ID].replace(
"_",
" ").title()
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)(
1208 CONF_NAME: f
"{friendly_identity} {command.replace('_', ' ').title()}",
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]))
1220 sensor_config = text_sensor_component.text_sensor_schema(
1221 IOHomeOneWayLastCommandTextSensor,
1222 entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
1223 ).extend(cv.COMPONENT_SCHEMA)(
1225 CONF_ID: identity[CONF_LAST_COMMAND_SENSOR_ID],
1226 CONF_NAME: f
"{friendly_identity} Last 1W Command",
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]))
1234 if identity[CONF_ENROLLMENT]:
1245 enroll_config = button_component.button_schema(
1246 IOHomeOneWayEnrollButton,
1247 entity_category=ENTITY_CATEGORY_CONFIG,
1248 ).extend(cv.COMPONENT_SCHEMA)(
1250 CONF_ID: identity[CONF_ENROLL_BUTTON_ID],
1251 CONF_NAME: f
"{friendly_identity} Enroll 1W Controller",
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]))
1261 """Create a hub-level arming switch (key extraction or key adoption).
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.
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.
1271 entity_config = switch_component.switch_schema(
1273 default_restore_mode=
"ALWAYS_OFF",
1274 entity_category=ENTITY_CATEGORY_CONFIG,
1275 ).extend(cv.COMPONENT_SCHEMA)(
1277 CONF_ID: config[id_key],
1281 entity = await switch_component.new_switch(entity_config)
1282 await cg.register_component(entity, entity_config)
1283 cg.add(entity.set_parent(var))
1287 """Create the hub-level "Scan Paired Devices" button.
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.
1295 entity_config = button_component.button_schema(
1296 IOHomeScanPairedDevicesButton,
1297 entity_category=ENTITY_CATEGORY_CONFIG,
1298 ).extend(cv.COMPONENT_SCHEMA)(
1300 CONF_ID: config[CONF_SCAN_PAIRED_DEVICES_BUTTON_ID],
1301 CONF_NAME:
"Scan Paired Devices",
1304 entity = await button_component.new_button(entity_config)
1305 await cg.register_component(entity, entity_config)
1306 cg.add(entity.set_parent(var))
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.
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.
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.
1329 cache_dir.mkdir(parents=
True, exist_ok=
True)
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()
1337 with urllib.request.urlopen(url, timeout=30)
as response:
1338 data = response.read()
1339 except urllib.error.HTTPError
as err:
1343 except urllib.error.URLError
as err:
1345 if expected_hash
is None or hashlib.md5(data).hexdigest() == expected_hash:
1346 cache_path.write_bytes(data)
1353 """Render a verified firmware/loader image as a C++ header.
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.
1364 words = [f
"0x{int.from_bytes(image.data[i : i + 4], 'big'):08X}" for i
in range(0, len(image.data), 4)]
1367 " " +
", ".join(words[i : i + words_per_line]) +
"," for i
in range(0, len(words), words_per_line)
1372 "// Auto-generated by the home_io_control lr1121_firmware_update build step. Do not edit.",
1373 "#include <cstddef>",
1374 "#include <cstdint>",
1376 "namespace esphome {",
1377 "namespace home_io_control {",
1379 f
"inline const uint32_t {array_name}[] = {{",
1382 f
"inline constexpr size_t {words_name} = {len(words)};",
1383 f
"inline constexpr uint16_t {version_name} = 0x{image.version:04X};",
1385 "} // namespace home_io_control",
1386 "} // namespace esphome",
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"
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"
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.
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.
1413 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
1414 cache_dir = CORE.data_dir /
"lr1121_firmware_cache"
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),
1424 raise cv.Invalid(f
"lr1121_firmware_update: {err}")
from err
1426 header_path = CORE.relative_src_path(
"lr1121_firmware_update_image.h")
1429 cg.add_define(
"IOHOME_LR1121_FIRMWARE_UPDATE")
1431 if CONF_LR1121_BOOTLOADER
in fw_config:
1434 entity_config = button_component.button_schema(
1435 IOHomeLr1121FirmwareUpdateButton,
1436 entity_category=ENTITY_CATEGORY_CONFIG,
1437 ).extend(cv.COMPONENT_SCHEMA)(
1439 CONF_ID: config[CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID],
1440 CONF_NAME:
"Flash LR1121 Radio Firmware",
1443 entity = await button_component.new_button(entity_config)
1444 await cg.register_component(entity, entity_config)
1445 cg.add(entity.set_parent(var))
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.
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.
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,
1467 raise cv.Invalid(f
"lr1121_firmware_update.bootloader: {err}")
from err
1469 header_path = CORE.relative_src_path(
"lr1121_bootloader_loader_image.h")
1472 cg.add_define(
"IOHOME_LR1121_BOOTLOADER_UPDATE")
1474 entity_config = switch_component.switch_schema(
1475 IOHomeLr1121BootloaderRewriteSwitch,
1476 default_restore_mode=
"ALWAYS_OFF",
1477 entity_category=ENTITY_CATEGORY_CONFIG,
1478 ).extend(cv.COMPONENT_SCHEMA)(
1480 CONF_ID: config[CONF_LR1121_BOOTLOADER_SWITCH_ID],
1481 CONF_NAME:
"Allow LR1121 Bootloader Rewrite (Irreversible)",
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)
_render_lr1121_image_header(image, array_name, words_name, version_name)
_render_lr1121_firmware_header(image)
_create_lr1121_bootloader_update(bootloader_config, config, var, cache_dir)
_create_lr1121_firmware_update(config, var)
validate_checksum_md5(value)
_create_oneway_controller_entities(identity, var)
_inject_hub_entity_id(config, *, flag_key, id_key, suffix, cls)
_inject_scan_paired_devices_button_id(config)
_cached_http_fetch(cache_dir)
_create_hub_arming_switch(config, var, *, cls, id_key, name)
_inject_recover_oneway_key_switch_id(config)
_inject_accept_foreign_pairing_switch_id(config)
validate_lr1121_firmware_source(value, *, expect_loader=False)
_create_scan_paired_devices_button(config, var)