Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
lr1121_firmware.py
Go to the documentation of this file.
1## @file
2## @brief Fetch, verify, and interpret a user-referenced LR1121 transceiver firmware image.
3## @ingroup hioc_codegen
4##
5## Pure logic with the network fetch injected as a callable, so every function here is
6## testable offline (see scripts/lr1121_firmware/tests/run_tests.py, wired into `make lint`).
7## Deliberately does NOT import esphome.config_validation: that test runner runs on a bare
8## host Python without ESPHome installed (mirroring scripts/corpus/*.py's independence), so a
9## module-level `import esphome...` here would break it. Every failure raises
10## Lr1121FirmwareError; __init__.py is the only place that translates it to cv.Invalid, at the
11## config-validation/codegen boundary where ESPHome is guaranteed to be present.
12##
13## The goal this module serves is "flash whatever LR1121 transceiver image the user points at",
14## not one specific release (ADR 0020). That is why validate_image()
15## deliberately does not gate on image size (published images range from 65 KB to 245 KB, all
16## legitimate) and why an unparseable filename version is carried through as "unknown" rather
17## than failing the build.
18
19import hashlib
20import re
21from dataclasses import dataclass
22
23
24class Lr1121FirmwareError(Exception):
25 """A configured firmware source is malformed, unverifiable, or fails validation."""
26
27
29 """The requested URL does not exist (e.g. HTTP 404).
30
31 A distinct subclass so fetch_and_verify() can tell "the optional .md5 sidecar is simply
32 absent" (fall back to checksum_md5) apart from any other fetch failure (network error,
33 malformed response, ...), which must not be silently swallowed.
34 """
35
36
37# github://<owner>/<repo>/<path/to/file.bin>[@ref] -- deliberately not ESPHome's own
38# cv.SOURCE_SCHEMA shorthand, which is repo-level only (no sub-path) and so cannot name a file.
39_GITHUB_SOURCE_RE = re.compile(r"^github://(?P<owner>[^/]+)/(?P<repo>[^/]+)/(?P<path>[^@]+?)(?:@(?P<ref>[^@]+))?$")
40
41_MD5_HEX_RE = re.compile(r"^[0-9a-f]{32}$")
42
43# `..._0104.bin` -> major=0x01, minor=0x04, matching the naming convention every image in
44# Lora-net/radio_firmware_images/lr1121/transceiver uses.
45_VERSION_RE = re.compile(r"_(?P<major>[0-9a-f]{2})(?P<minor>[0-9a-f]{2})\.bin$", re.IGNORECASE)
46
47
49 """Parse a `github://` shorthand into (owner, repo, path, ref).
50
51 `ref` defaults to "HEAD" when no `@ref` suffix is present.
52 @raises Lr1121FirmwareError if `value` doesn't match the expected shape.
53 """
54 if not isinstance(value, str):
55 raise Lr1121FirmwareError(f"source must be a string, got {type(value).__name__}")
56 match = _GITHUB_SOURCE_RE.match(value.strip())
57 if match is None:
59 f"Invalid source {value!r}; expected github://<owner>/<repo>/<path/to/file.bin>[@ref]"
60 )
61 return match.group("owner"), match.group("repo"), match.group("path"), match.group("ref") or "HEAD"
62
63
64def resolve_raw_url(owner, repo, path, ref):
65 """Resolve a parsed github:// source to its raw.githubusercontent.com URL."""
66 return f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}"
67
68
70 """Extract the hash from a `.bin.md5` sidecar's contents.
71
72 Only the first whitespace-separated token is the hash. The second field is NOT a stable
73 filename -- some published sidecars carry a bare filename, others a relative release-artefact
74 path -- so it is read and then deliberately never validated.
75 @raises Lr1121FirmwareError if the first token isn't 32 lowercase hex characters.
76 """
77 stripped = text.strip()
78 token = stripped.split()[0] if stripped else ""
79 lowered = token.lower()
80 if not _MD5_HEX_RE.match(lowered):
81 raise Lr1121FirmwareError(f"Malformed MD5 sidecar (expected 32 hex chars as the first token): {text!r}")
82 return lowered
83
84
85def validate_image_class(path, *, expect_loader):
86 """Reject a `source:` naming the wrong LR1121 image class, by filename.
87
88 A blocklist of two known-bad patterns, not an allowlist requiring e.g. "transceiver" in the
89 name -- an allowlist would break a user mirroring images under their own names. Semtech's own
90 tool refuses a loader image as an ordinary firmware target outright; this project had no
91 such guard, and a loader/modem source previously passed validation
92 and was routed through the two-press UNKNOWN_TARGET confirmation, leaving the radio running a
93 non-transceiver image after the flash -- SPI still answers, but there is no radio function.
94 @param expect_loader True when validating the bootloader sub-block's `source:` (must BE a
95 loader image); False for the ordinary transceiver `source:` (must NOT be one).
96 @raises Lr1121FirmwareError on a filename/class mismatch.
97 """
98 name = path.rsplit("/", 1)[-1].lower()
99 is_loader = "_loader_" in name
100 if expect_loader:
101 if not is_loader:
103 f"bootloader: source must be a bootloader loader image (filename containing '_loader_'), got {path!r}"
104 )
105 return
106 if is_loader:
108 f"source {path!r} looks like a bootloader loader image (filename containing '_loader_'); loader images "
109 "are only usable through the bootloader: sub-block, not as the ordinary transceiver source:"
110 )
111 if "_modem_" in name:
113 f"source {path!r} looks like a LoRa Basics Modem-E image (filename containing '_modem_'); modem "
114 "firmware is a different product mode this component does not support"
115 )
116
117
119 """Derive a firmware version from a filename like `lr1121_transceiver_0104.bin`.
120
121 @return The version as a 16-bit int (major<<8 | minor), or None when the filename carries
122 no parseable version -- that is NOT an error, see fetch_and_verify().
123 """
124 match = _VERSION_RE.search(path)
125 if match is None:
126 return None
127 return (int(match.group("major"), 16) << 8) | int(match.group("minor"), 16)
128
129
130def validate_image(data, url):
131 """Validate that `data` has the shape of an LR1121 transceiver firmware image.
132
133 Deliberately narrow -- length must be a whole (and non-zero) number of 32-bit words, and the
134 URL must name the chip family. No size band: published images range from 65 KB to 245 KB, both
135 legitimate, so guessing a plausible size band from a sample would reject valid images.
136 @raises Lr1121FirmwareError on any check failing.
137 """
138 if len(data) == 0:
139 # `len(data) % 4 == 0` is also (trivially) true for an empty image, so this must be
140 # checked separately -- otherwise a zero-byte download generates a zero-word array, and a
141 # press erases the chip and writes nothing back, an unrecoverable-by-this-project state.
142 raise Lr1121FirmwareError(f"Firmware image is empty (0 bytes), refusing to generate a zero-word image: {url}")
143 if len(data) % 4 != 0:
145 f"Firmware image length {len(data)} is not a multiple of 4 bytes (not a valid raw word image): {url}"
146 )
147 if "lr1121" not in url.lower():
148 raise Lr1121FirmwareError(f"Resolved firmware URL does not look like an LR1121 image: {url}")
149
150
151def validate_bootloader_reachability(radio_type, has_busy_pin, busy_pin_inverted):
152 """Reject configurations that can't reach the LR1121 bootloader, or would reach it wrong.
153
154 Pure mirror of __init__.py's schema-time checks -- kept here, not there, so it is
155 host-testable like every other check in this module (see file header). @raises
156 Lr1121FirmwareError; __init__.py is the only place that translates it to cv.Invalid.
157 """
158 if radio_type != "lr1121":
159 raise Lr1121FirmwareError("lr1121_firmware_update requires radio_type: lr1121")
160 if not has_busy_pin:
161 raise Lr1121FirmwareError("lr1121_firmware_update requires busy_pin (needed to enter the chip's bootloader)")
162 if busy_pin_inverted:
164 "busy_pin must not be inverted when lr1121_firmware_update is configured: entering "
165 "the LR1121 bootloader drives it to a physical LOW, and an inverted pin would invert "
166 "that level"
167 )
168
169
170def resolve_target_version(source, target_version):
171 """Resolve the target firmware version from `source`/`target_version` without any network access.
172
173 Same resolution order fetch_and_verify() uses (explicit override, else filename, else 0 ==
174 unknown) -- factored out so schema-time bootloader-compatibility checks (see
175 classify_bootloader_upgrade_class() below) can classify a `bootloader:` block's outer target
176 before to_code()'s network fetch happens.
177 @raises Lr1121FirmwareError if `source` doesn't match the expected github:// shape.
178 """
179 _, _, path, _ = parse_github_source(source)
180 return target_version or parse_version_from_filename(path) or 0
181
182
183# Mirrors lr1121_firmware_decisions.h's LR1121_KNOWN_BOOTLOADER_REQUIREMENTS -- duplicated here
184# because this module deliberately does not import ESPHome or link against the C++ decision
185# header (see the file header), yet the bootloader:-block build-time check below must classify
186# the outer target before to_code()'s network fetch happens. An advisory snapshot, not a
187# compatibility authority (same discipline as the C++ table): a target absent from it is
188# "unverified", never "incompatible" -- see classify_bootloader_upgrade_class(). Keep the two
189# tables in sync by hand when Semtech publishes a new pairing; last checked 2026-08-07.
190KNOWN_BOOTLOADER_REQUIREMENTS = {
191 0x0101: 0x2100,
192 0x0102: 0x2100,
193 0x0103: 0x2100,
194 0x0104: 0x2101,
195}
196LR1121_BOOTLOADER_2100 = 0x2100
197LR1121_BOOTLOADER_2101 = 0x2101
198
199
201 """Classify whether a `bootloader:` sub-block is safe given the outer `source:`'s target.
202
203 Implements the build-time half of ADR 0021's compatibility rule: it
204 must stay three-way, like the C++ side's runtime compatibility rule, or it rots the first time
205 Semtech ships a new firmware release this table has never heard of.
206 @return "accept" (target known, requires the new bootloader 0x2101 -- C3),
207 "hard_error" (target known, requires the OLD bootloader 0x2100 -- C4; post-upgrade
208 this image would be unflashable, so the block would be arming a trap),
209 "unknown" (target absent from the table -- C5; accept with a warning, since refusing
210 outright would rot on the next Semtech release).
211 """
212 required = KNOWN_BOOTLOADER_REQUIREMENTS.get(target_fw)
213 if required is None:
214 return "unknown"
215 return "accept" if required == LR1121_BOOTLOADER_2101 else "hard_error"
216
217
218@dataclass
220 """Result of fetch_and_verify(): a verified image ready to embed in the generated header."""
221
222 data: bytes
223 version: int ## Target firmware version (major<<8 | minor), or 0 when unknown.
224 url: str ## Resolved source URL, for logging/error messages.
225
226
227def fetch_and_verify(source, ref, checksum_md5, target_version, fetch):
228 """Fetch, verify, and interpret the firmware image a YAML block points at.
229
230 Ties together every function above: resolves the URL, fetches the `.bin`, fetches its
231 `.md5` sidecar (falling back to `checksum_md5` when absent, failing only if neither is
232 available), verifies the hash, validates the image shape, and resolves the target version.
233
234 @param source Raw `source:` YAML value (a github:// shorthand string).
235 @param ref Optional YAML `ref:` override; takes precedence over an `@ref` embedded in `source`.
236 @param checksum_md5 Optional user-supplied hash (lowercase or uppercase hex), or None.
237 @param target_version Optional user-supplied version override (int), or None/0 to derive
238 from the filename.
239 @param fetch Callable `fetch(url: str, expected_hash: str | None = None) -> bytes`, raising
240 Lr1121FirmwareNotFoundError for a definite "does not exist" (e.g. HTTP 404) and any
241 other exception on a harder failure (network error, ...), which is left to propagate
242 uncaught. `expected_hash`, when given, is a hint a caching `fetch` may use to key its
243 cache (see __init__.py's _cached_http_fetch) -- it carries no meaning here beyond that.
244 @return A verified FirmwareImage.
245 @raises Lr1121FirmwareError (or whatever `fetch` raises) on any failure.
246 """
247 owner, repo, path, parsed_ref = parse_github_source(source)
248 effective_ref = ref or parsed_ref
249 url = resolve_raw_url(owner, repo, path, effective_ref)
250
251 # The expected hash is resolved *before* fetching the .bin (not after, as an earlier revision
252 # did) specifically so it can be passed into that fetch as a cache-key ingredient: with
253 # the default `ref: HEAD` the URL alone never changes, so a cache keyed on the URL alone can't
254 # be invalidated by fixing a wrong checksum_md5 -- a corrupt/truncated download would poison
255 # every future build permanently. Keying on the hash too means a corrected expectation misses
256 # the poisoned entry and re-fetches.
257 sidecar_hash = None
258 try:
259 sidecar_text = fetch(url + ".md5").decode("utf-8")
260 except Lr1121FirmwareNotFoundError:
261 sidecar_text = None
262 if sidecar_text is not None:
263 sidecar_hash = parse_md5_sidecar(sidecar_text)
264
265 normalized_checksum = checksum_md5.lower() if checksum_md5 else None
266 if sidecar_hash is None and normalized_checksum is None:
268 f"No .md5 sidecar found at {url}.md5 and no checksum_md5 was configured; cannot verify the download."
269 )
270 if sidecar_hash is not None and normalized_checksum is not None and sidecar_hash != normalized_checksum:
272 f"checksum_md5 ({normalized_checksum}) does not match the .md5 sidecar ({sidecar_hash}) for {url}"
273 )
274 expected_hash = sidecar_hash or normalized_checksum
275
276 data = fetch(url, expected_hash)
277
278 actual_hash = hashlib.md5(data).hexdigest() # noqa: S324 -- corruption/transit check, not tamper protection.
279 if actual_hash != expected_hash:
280 raise Lr1121FirmwareError(f"MD5 mismatch for {url}: expected {expected_hash}, got {actual_hash}")
281
282 validate_image(data, url)
283
284 version = target_version or parse_version_from_filename(path) or 0
285
286 return FirmwareImage(data=data, version=version, url=url)
validate_bootloader_reachability(radio_type, has_busy_pin, busy_pin_inverted)
validate_image_class(path, *, expect_loader)
resolve_raw_url(owner, repo, path, ref)
fetch_and_verify(source, ref, checksum_md5, target_version, fetch)
resolve_target_version(source, target_version)