15import esphome.codegen
as cg
16import esphome.config_validation
as cv
18from esphome.components
import button
as button_component
19from esphome.components
import switch
as switch_component
20from esphome.const
import (
26 ENTITY_CATEGORY_CONFIG,
28from esphome.core
import CORE, ID
29from esphome.helpers
import write_file_if_changed
31from .
import lr1121_firmware
32from .hub_names
import (
35 CONF_LR1121_BOOTLOADER,
36 CONF_LR1121_BOOTLOADER_SWITCH_ID,
37 CONF_LR1121_FIRMWARE_UPDATE,
38 CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID,
41 IOHomeLr1121BootloaderRewriteSwitch,
42 IOHomeLr1121FirmwareUpdateButton,
45_LOGGER = logging.getLogger(__name__)
49 """Validate the lr1121_firmware_update `source:` shorthand at schema time.
51 Checks the shape (github://owner/repo/path[@ref]) and the image class (transceiver vs.
52 loader vs. modem, by filename -- see lr1121_firmware.validate_image_class()). The network
53 fetch and MD5/image-content verification happen later, in to_code(), where a failure is
54 still a build-time error but one that needs the network anyway.
55 @param expect_loader True for the bootloader sub-block's `source:` (must be a loader image),
56 False for the ordinary transceiver `source:` (must not be one).
58 value = cv.string_strict(value)
60 _, _, path, _ = lr1121_firmware.parse_github_source(value)
61 lr1121_firmware.validate_image_class(path, expect_loader=expect_loader)
63 raise cv.Invalid(str(err))
from err
68 """Validate checksum_md5 as exactly 32 hex characters (MD5)."""
69 value = cv.string_strict(value).lower()
71 raise cv.Invalid(
"checksum_md5 must be exactly 32 hex characters (MD5)")
74 except ValueError
as err:
75 raise cv.Invalid(
"checksum_md5 must be valid hexadecimal")
from err
83LR1121_BOOTLOADER_SCHEMA = cv.Schema(
86 cv.Optional(CONF_REF): cv.string_strict,
87 cv.Optional(CONF_CHECKSUM_MD5): validate_checksum_md5,
91LR1121_FIRMWARE_UPDATE_SCHEMA = cv.Schema(
93 cv.Required(CONF_SOURCE): validate_lr1121_firmware_source,
94 cv.Optional(CONF_REF): cv.string_strict,
95 cv.Optional(CONF_CHECKSUM_MD5): validate_checksum_md5,
100 cv.Optional(CONF_TARGET_VERSION): cv.hex_int,
103 cv.Optional(CONF_LR1121_BOOTLOADER): LR1121_BOOTLOADER_SCHEMA,
109 """Implement the build-time compatibility rule for the bootloader: sub-block (ADR 0021).
111 Classifies the *outer* source:'s target against LR1121_KNOWN_BOOTLOADER_REQUIREMENTS without
112 any network access (both source: filenames are already schema-validated shapes at this point,
113 so parsing them again here is free). Deliberately three-way, like the runtime compatibility
114 rule: an unrecognised target warns rather than errors, so the feature doesn't rot on Semtech's
115 next release (see lr1121_firmware.classify_bootloader_upgrade_class()'s doc comment).
117 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
118 if CONF_LR1121_BOOTLOADER
not in fw_config:
121 target_fw = lr1121_firmware.resolve_target_version(fw_config[CONF_SOURCE], fw_config.get(CONF_TARGET_VERSION))
122 upgrade_class = lr1121_firmware.classify_bootloader_upgrade_class(target_fw)
123 if upgrade_class ==
"hard_error":
125 f
"lr1121_firmware_update.bootloader: is configured, but source: targets firmware 0x{target_fw:04X}, "
126 "which is known to require bootloader 0x2100 -- after the bootloader rewrite this image would be "
127 "unflashable, so this configuration would arm a trap. Point source: at a firmware version requiring "
128 "bootloader 0x2101 (e.g. 0x0104), or remove the bootloader: block."
130 if upgrade_class ==
"unknown":
132 "lr1121_firmware_update.bootloader: is configured, but source: targets an unrecognized firmware "
133 "version (0x%04X); the bootloader-rewrite path will be inert at runtime until this build's "
134 "compatibility table is extended for it (see lr1121_firmware_decisions.h)",
138 parent_id = config[CONF_ID]
139 base = parent_id.id
if parent_id.id
else "home_io_control"
140 config[CONF_LR1121_BOOTLOADER_SWITCH_ID] = ID(
141 f
"{base}_lr1121_bootloader_switch",
143 type=IOHomeLr1121BootloaderRewriteSwitch,
149 """Gate + inject the button ID for the optional lr1121_firmware_update: block.
151 Only runs when the block is present. Rejects configurations that can't reach the LR1121
152 bootloader at all (wrong radio_type, missing busy_pin) or that would silently invert the
153 bootloader-entry level (busy_pin inverted: true — bootloader entry drives BUSY to a physical
154 LOW; see radio_lr1121_firmware_updater.h). Also injects the flash button's companion ID at
155 validation time — see CONF_ACCEPT_FOREIGN_PAIRING_SWITCH_ID's comment (hub_names.py) for why that
156 can't wait until to_code(). The bootloader:-specific checks (C3-C5, and the companion arming
157 switch's ID) live in _validate_lr1121_bootloader_block() above, called at the end of this
158 function so config[CONF_ID] and the reachability checks are already settled.
160 if CONF_LR1121_FIRMWARE_UPDATE
not in config:
163 lr1121_firmware.validate_bootloader_reachability(
164 radio_type=config[CONF_RADIO_TYPE],
165 has_busy_pin=CONF_BUSY_PIN
in config,
166 busy_pin_inverted=config.get(CONF_BUSY_PIN, {}).get(CONF_INVERTED,
False),
169 raise cv.Invalid(str(err))
from err
171 parent_id = config[CONF_ID]
172 base = parent_id.id
if parent_id.id
else "home_io_control"
173 config[CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID] = ID(
174 f
"{base}_lr1121_firmware_update_button",
176 type=IOHomeLr1121FirmwareUpdateButton,
182 """Build a `fetch(url, expected_hash=None) -> bytes` callable for
183 lr1121_firmware.fetch_and_verify(), backed by an on-disk cache so repeat and offline builds
184 don't re-download the same source.
186 The cache key incorporates `expected_hash` (the MD5 fetch_and_verify() already resolved from
187 the `.md5` sidecar or `checksum_md5:` before calling this for the `.bin`) rather than being
188 `sha256(url)` alone. With the default `ref: HEAD` the URL never changes, so a plain
189 url-only key means a corrupt/truncated download poisons the cache permanently -- no config
190 change can ever invalidate it, since nothing about the request changes on retry. Folding the
191 expected hash in means correcting a wrong `checksum_md5:` (or a fixed upstream sidecar) misses
192 the poisoned entry and forces a fresh download. The `.md5` sidecar fetch itself has no
193 expected_hash to key on (chicken-and-egg -- it's what supplies one for the .bin) and is cached
194 under the URL alone; a corrupted sidecar is a much smaller/rarer risk than a corrupted 64+ KB
195 binary, and the cache directory below is a manual escape hatch either way.
197 Data that fails its own hash check is deliberately never written to the cache (verify-before-store):
198 a transient network corruption then simply retries cleanly on the next build, with no
199 config change needed at all.
201 cache_dir.mkdir(parents=
True, exist_ok=
True)
203 def fetch(url, expected_hash=None):
204 cache_key = hashlib.sha256(f
"{url}|{expected_hash or ''}".encode(
"utf-8")).hexdigest()
205 cache_path = cache_dir / cache_key
206 if cache_path.exists():
207 return cache_path.read_bytes()
209 with urllib.request.urlopen(url, timeout=30)
as response:
210 data = response.read()
211 except urllib.error.HTTPError
as err:
215 except urllib.error.URLError
as err:
217 if expected_hash
is None or hashlib.md5(data).hexdigest() == expected_hash:
218 cache_path.write_bytes(data)
225 """Render a verified firmware/loader image as a C++ header.
227 Each raw 4-byte chunk of the `.bin` is exactly one big-endian word as Semtech's own image
228 format already lays it out, so this only has to slice and format, not transform, the bytes.
229 `inline const` (not `constexpr`) for the array: it is never used in a constant expression, so
230 forcing constant-evaluation of up to ~61k elements would only cost compile time; `const` at
231 namespace scope still lands in `.rodata` (flash) on ESP32, not RAM. Shared by
232 _render_lr1121_firmware_header() (the transceiver image) and the bootloader loader image --
233 same shape, different symbol names so both headers can be included from the same translation
234 unit without colliding.
236 words = [f
"0x{int.from_bytes(image.data[i : i + 4], 'big'):08X}" for i
in range(0, len(image.data), 4)]
239 " " +
", ".join(words[i : i + words_per_line]) +
"," for i
in range(0, len(words), words_per_line)
244 "// Auto-generated by the home_io_control lr1121_firmware_update build step. Do not edit.",
245 "#include <cstddef>",
246 "#include <cstdint>",
248 "namespace esphome {",
249 "namespace home_io_control {",
251 f
"inline const uint32_t {array_name}[] = {{",
254 f
"inline constexpr size_t {words_name} = {len(words)};",
255 f
"inline constexpr uint16_t {version_name} = 0x{image.version:04X};",
257 "} // namespace home_io_control",
258 "} // namespace esphome",
265 """Render the verified transceiver firmware image as a C++ header."""
267 image,
"LR1121_FIRMWARE_UPDATE_IMAGE",
"LR1121_FIRMWARE_UPDATE_IMAGE_WORDS",
"LR1121_FIRMWARE_UPDATE_TARGET_VERSION"
272 """Render the verified bootloader *loader* image as a C++ header (ADR 0021)."""
274 image,
"LR1121_BOOTLOADER_LOADER_IMAGE",
"LR1121_BOOTLOADER_LOADER_IMAGE_WORDS",
"LR1121_BOOTLOADER_LOADER_FW"
279 """Fetch/verify the configured firmware image, generate its header, set the build flag that
280 gates the whole feature, and create the "Flash LR1121 Radio Firmware" button.
282 The block's mere presence in YAML is the build flag (ADR 0020) — there is no
283 separate enable switch, so entering/leaving flash mode is a recompile + OTA each way.
285 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
286 cache_dir = CORE.data_dir /
"lr1121_firmware_cache"
288 image = lr1121_firmware.fetch_and_verify(
289 source=fw_config[CONF_SOURCE],
290 ref=fw_config.get(CONF_REF),
291 checksum_md5=fw_config.get(CONF_CHECKSUM_MD5),
292 target_version=fw_config.get(CONF_TARGET_VERSION),
296 raise cv.Invalid(f
"lr1121_firmware_update: {err}")
from err
298 header_path = CORE.relative_src_path(
"lr1121_firmware_update_image.h")
301 cg.add_define(
"IOHOME_LR1121_FIRMWARE_UPDATE")
303 if CONF_LR1121_BOOTLOADER
in fw_config:
306 entity_config = button_component.button_schema(
307 IOHomeLr1121FirmwareUpdateButton,
308 entity_category=ENTITY_CATEGORY_CONFIG,
309 ).extend(cv.COMPONENT_SCHEMA)(
311 CONF_ID: config[CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID],
312 CONF_NAME:
"Flash LR1121 Radio Firmware",
315 entity = await button_component.new_button(entity_config)
316 await cg.register_component(entity, entity_config)
317 cg.add(entity.set_parent(var))
321 """Fetch/verify the configured loader image, generate its header, set the build flag that
322 gates the bootloader-rewrite feature, and create the arming switch.
324 Mirrors create_lr1121_firmware_update() above -- same "block's presence is the build flag"
325 shape, one level down (ADR 0021). `target_version` is not passed to
326 fetch_and_verify(): the loader is not a "target" the way the transceiver image is, its version
327 is only ever compared for *equality* against the currently-running bootloader (Semtech's
328 rule), so there is nothing to override.
331 loader_image = lr1121_firmware.fetch_and_verify(
332 source=bootloader_config[CONF_SOURCE],
333 ref=bootloader_config.get(CONF_REF),
334 checksum_md5=bootloader_config.get(CONF_CHECKSUM_MD5),
339 raise cv.Invalid(f
"lr1121_firmware_update.bootloader: {err}")
from err
341 header_path = CORE.relative_src_path(
"lr1121_bootloader_loader_image.h")
344 cg.add_define(
"IOHOME_LR1121_BOOTLOADER_UPDATE")
346 entity_config = switch_component.switch_schema(
347 IOHomeLr1121BootloaderRewriteSwitch,
348 default_restore_mode=
"ALWAYS_OFF",
349 entity_category=ENTITY_CATEGORY_CONFIG,
350 ).extend(cv.COMPONENT_SCHEMA)(
352 CONF_ID: config[CONF_LR1121_BOOTLOADER_SWITCH_ID],
353 CONF_NAME:
"Allow LR1121 Bootloader Rewrite (Irreversible)",
366 entity = await switch_component.new_switch(entity_config)
367 await cg.register_component(entity, entity_config)
368 cg.add(entity.set_parent(var))
create_lr1121_firmware_update(config, var)
_create_lr1121_bootloader_update(bootloader_config, config, var, cache_dir)
validate_lr1121_firmware_source(value, *, expect_loader=False)
_cached_http_fetch(cache_dir)
validate_lr1121_firmware_update(config)
_render_lr1121_image_header(image, array_name, words_name, version_name)
validate_checksum_md5(value)
_validate_lr1121_bootloader_block(config)
_render_lr1121_bootloader_loader_header(image)
_render_lr1121_firmware_header(image)