Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
lr1121_update_codegen.py
Go to the documentation of this file.
1## @file
2## @brief Schema, validation and code generation for ``home_io_control: lr1121_firmware_update:``.
3## @ingroup hioc_codegen
4##
5## Validates the ``source:``/``checksum_md5:`` shorthand at schema time, then in to_code()
6## fetches the image (cached), verifies it with lr1121_firmware.py and renders it into a
7## generated C++ header, together with the update button and, for the ``bootloader:``
8## sub-block, the loader image and its arming switch.
9
10import hashlib
11import logging
12import urllib.error
13import urllib.request
14
15import esphome.codegen as cg
16import esphome.config_validation as cv
17# Aliased so they cannot be mistaken for this package's own platform modules (see hub_names.py).
18from esphome.components import button as button_component
19from esphome.components import switch as switch_component
20from esphome.const import (
21 CONF_ID,
22 CONF_INVERTED,
23 CONF_NAME,
24 CONF_REF,
25 CONF_SOURCE,
26 ENTITY_CATEGORY_CONFIG,
27)
28from esphome.core import CORE, ID
29from esphome.helpers import write_file_if_changed
30
31from . import lr1121_firmware
32from .hub_names import (
33 CONF_BUSY_PIN,
34 CONF_CHECKSUM_MD5,
35 CONF_LR1121_BOOTLOADER,
36 CONF_LR1121_BOOTLOADER_SWITCH_ID,
37 CONF_LR1121_FIRMWARE_UPDATE,
38 CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID,
39 CONF_RADIO_TYPE,
40 CONF_TARGET_VERSION,
41 IOHomeLr1121BootloaderRewriteSwitch,
42 IOHomeLr1121FirmwareUpdateButton,
43)
44
45_LOGGER = logging.getLogger(__name__)
46
47
48def validate_lr1121_firmware_source(value, *, expect_loader=False):
49 """Validate the lr1121_firmware_update `source:` shorthand at schema time.
50
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).
57 """
58 value = cv.string_strict(value)
59 try:
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
64 return value
65
66
68 """Validate checksum_md5 as exactly 32 hex characters (MD5)."""
69 value = cv.string_strict(value).lower()
70 if len(value) != 32:
71 raise cv.Invalid("checksum_md5 must be exactly 32 hex characters (MD5)")
72 try:
73 int(value, 16)
74 except ValueError as err:
75 raise cv.Invalid("checksum_md5 must be valid hexadecimal") from err
76 return value
77
78
79# The bootloader sub-block's `source:` must BE a loader image (expect_loader=True) -- the
80# symmetric guard to the outer schema's default expect_loader=False (C8 in the bootloader update
81# ADR 0021): a transceiver image in this slot would erase and overwrite the wrong thing
82# at stage 1a.
83LR1121_BOOTLOADER_SCHEMA = cv.Schema(
84 {
85 cv.Required(CONF_SOURCE): lambda value: validate_lr1121_firmware_source(value, expect_loader=True),
86 cv.Optional(CONF_REF): cv.string_strict,
87 cv.Optional(CONF_CHECKSUM_MD5): validate_checksum_md5,
88 }
89)
90
91LR1121_FIRMWARE_UPDATE_SCHEMA = cv.Schema(
92 {
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,
96 # target_version exists solely as an escape hatch for a mirrored/renamed image whose
97 # filename carries no version — NOT as a compatibility declaration. There is deliberately
98 # no `requires_bootloader:` key: a user-declared compatibility claim is the wrong shape
99 # for a safety check, since the build can derive it from the filename instead.
100 cv.Optional(CONF_TARGET_VERSION): cv.hex_int,
101 # Presence is the build flag for the bootloader-rewrite feature, exactly as the outer
102 # block's presence already is for the transceiver-update feature -- see ADR 0021.
103 cv.Optional(CONF_LR1121_BOOTLOADER): LR1121_BOOTLOADER_SCHEMA,
104 }
105)
106
107
109 """Implement the build-time compatibility rule for the bootloader: sub-block (ADR 0021).
110
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).
116 """
117 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
118 if CONF_LR1121_BOOTLOADER not in fw_config:
119 return config
120
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":
124 raise cv.Invalid(
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."
129 )
130 if upgrade_class == "unknown":
131 _LOGGER.warning(
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)",
135 target_fw,
136 )
137
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",
142 is_declaration=True,
143 type=IOHomeLr1121BootloaderRewriteSwitch,
144 )
145 return config
146
147
149 """Gate + inject the button ID for the optional lr1121_firmware_update: block.
150
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.
159 """
160 if CONF_LR1121_FIRMWARE_UPDATE not in config:
161 return config
162 try:
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),
167 )
169 raise cv.Invalid(str(err)) from err
170
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",
175 is_declaration=True,
176 type=IOHomeLr1121FirmwareUpdateButton,
177 )
179
180
181def _cached_http_fetch(cache_dir):
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.
185
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.
196
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.
200 """
201 cache_dir.mkdir(parents=True, exist_ok=True)
202
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()
208 try:
209 with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
210 data = response.read()
211 except urllib.error.HTTPError as err:
212 if err.code == 404:
214 raise lr1121_firmware.Lr1121FirmwareError(f"HTTP {err.code} fetching {url}") from err
215 except urllib.error.URLError as err:
216 raise lr1121_firmware.Lr1121FirmwareError(f"Failed to fetch {url}: {err}") from err
217 if expected_hash is None or hashlib.md5(data).hexdigest() == expected_hash: # noqa: S324
218 cache_path.write_bytes(data)
219 return data
220
221 return fetch
222
223
224def _render_lr1121_image_header(image, array_name, words_name, version_name):
225 """Render a verified firmware/loader image as a C++ header.
226
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.
235 """
236 words = [f"0x{int.from_bytes(image.data[i : i + 4], 'big'):08X}" for i in range(0, len(image.data), 4)]
237 words_per_line = 8
238 body_lines = [
239 " " + ", ".join(words[i : i + words_per_line]) + "," for i in range(0, len(words), words_per_line)
240 ]
241 return "\n".join(
242 [
243 "#pragma once",
244 "// Auto-generated by the home_io_control lr1121_firmware_update build step. Do not edit.",
245 "#include <cstddef>",
246 "#include <cstdint>",
247 "",
248 "namespace esphome {",
249 "namespace home_io_control {",
250 "",
251 f"inline const uint32_t {array_name}[] = {{",
252 *body_lines,
253 "};",
254 f"inline constexpr size_t {words_name} = {len(words)};",
255 f"inline constexpr uint16_t {version_name} = 0x{image.version:04X};",
256 "",
257 "} // namespace home_io_control",
258 "} // namespace esphome",
259 "",
260 ]
261 )
262
263
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"
268 )
269
270
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"
275 )
276
277
278async def create_lr1121_firmware_update(config, var):
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.
281
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.
284 """
285 fw_config = config[CONF_LR1121_FIRMWARE_UPDATE]
286 cache_dir = CORE.data_dir / "lr1121_firmware_cache"
287 try:
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),
293 fetch=_cached_http_fetch(cache_dir),
294 )
296 raise cv.Invalid(f"lr1121_firmware_update: {err}") from err
297
298 header_path = CORE.relative_src_path("lr1121_firmware_update_image.h")
299 write_file_if_changed(header_path, _render_lr1121_firmware_header(image))
300
301 cg.add_define("IOHOME_LR1121_FIRMWARE_UPDATE")
302
303 if CONF_LR1121_BOOTLOADER in fw_config:
304 await _create_lr1121_bootloader_update(fw_config[CONF_LR1121_BOOTLOADER], config, var, cache_dir)
305
306 entity_config = button_component.button_schema(
307 IOHomeLr1121FirmwareUpdateButton,
308 entity_category=ENTITY_CATEGORY_CONFIG,
309 ).extend(cv.COMPONENT_SCHEMA)(
310 {
311 CONF_ID: config[CONF_LR1121_FIRMWARE_UPDATE_BUTTON_ID],
312 CONF_NAME: "Flash LR1121 Radio Firmware",
313 }
314 )
315 entity = await button_component.new_button(entity_config)
316 await cg.register_component(entity, entity_config)
317 cg.add(entity.set_parent(var))
318
319
320async def _create_lr1121_bootloader_update(bootloader_config, config, var, cache_dir):
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.
323
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.
329 """
330 try:
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),
335 target_version=None,
336 fetch=_cached_http_fetch(cache_dir),
337 )
339 raise cv.Invalid(f"lr1121_firmware_update.bootloader: {err}") from err
340
341 header_path = CORE.relative_src_path("lr1121_bootloader_loader_image.h")
342 write_file_if_changed(header_path, _render_lr1121_bootloader_loader_header(loader_image))
343
344 cg.add_define("IOHOME_LR1121_BOOTLOADER_UPDATE")
345
346 entity_config = switch_component.switch_schema(
347 IOHomeLr1121BootloaderRewriteSwitch,
348 default_restore_mode="ALWAYS_OFF", # never auto-arm after a reboot -- ADR 0021
349 entity_category=ENTITY_CATEGORY_CONFIG,
350 ).extend(cv.COMPONENT_SCHEMA)(
351 {
352 CONF_ID: config[CONF_LR1121_BOOTLOADER_SWITCH_ID],
353 CONF_NAME: "Allow LR1121 Bootloader Rewrite (Irreversible)",
354 # Deliberately NOT disabled_by_default. It reads like the right call for an irreversible
355 # control, but in Home Assistant that disables the entity in the registry: it cannot be
356 # toggled until the user finds it and enables it by hand, which makes the documented
357 # procedure ("turn the switch on, press the button") simply not work. It also defeats
358 # ADR 0021's reason for choosing a switch over an invisible confirmation window -- that
359 # the armed state is answerable by looking -- since a disabled entity is not shown at
360 # all. entity_category=config is the right amount of out-of-the-way: it files the switch
361 # under Configuration rather than among the primary controls, and it stays usable.
362 # The real gating is elsewhere and unaffected: the bootloader: block must be in YAML and
363 # the firmware rebuilt, and the switch is off on every boot (ALWAYS_OFF).
364 }
365 )
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_bootloader_update(bootloader_config, config, var, cache_dir)
validate_lr1121_firmware_source(value, *, expect_loader=False)
_render_lr1121_image_header(image, array_name, words_name, version_name)