21from dataclasses
import dataclass
25 """A configured firmware source is malformed, unverifiable, or fails validation."""
29 """The requested URL does not exist (e.g. HTTP 404).
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.
39_GITHUB_SOURCE_RE = re.compile(
r"^github://(?P<owner>[^/]+)/(?P<repo>[^/]+)/(?P<path>[^@]+?)(?:@(?P<ref>[^@]+))?$")
41_MD5_HEX_RE = re.compile(
r"^[0-9a-f]{32}$")
45_VERSION_RE = re.compile(
r"_(?P<major>[0-9a-f]{2})(?P<minor>[0-9a-f]{2})\.bin$", re.IGNORECASE)
49 """Parse a `github://` shorthand into (owner, repo, path, ref).
51 `ref` defaults to "HEAD" when no `@ref` suffix is present.
52 @raises Lr1121FirmwareError if `value` doesn't match the expected shape.
54 if not isinstance(value, str):
56 match = _GITHUB_SOURCE_RE.match(value.strip())
59 f
"Invalid source {value!r}; expected github://<owner>/<repo>/<path/to/file.bin>[@ref]"
61 return match.group(
"owner"), match.group(
"repo"), match.group(
"path"), match.group(
"ref")
or "HEAD"
65 """Resolve a parsed github:// source to its raw.githubusercontent.com URL."""
66 return f
"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}"
70 """Extract the hash from a `.bin.md5` sidecar's contents.
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.
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}")
86 """Reject a `source:` naming the wrong LR1121 image class, by filename.
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.
98 name = path.rsplit(
"/", 1)[-1].lower()
99 is_loader =
"_loader_" in name
103 f
"bootloader: source must be a bootloader loader image (filename containing '_loader_'), got {path!r}"
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:"
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"
119 """Derive a firmware version from a filename like `lr1121_transceiver_0104.bin`.
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().
124 match = _VERSION_RE.search(path)
127 return (int(match.group(
"major"), 16) << 8) | int(match.group(
"minor"), 16)
131 """Validate that `data` has the shape of an LR1121 transceiver firmware image.
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.
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}"
147 if "lr1121" not in url.lower():
148 raise Lr1121FirmwareError(f
"Resolved firmware URL does not look like an LR1121 image: {url}")
152 """Reject configurations that can't reach the LR1121 bootloader, or would reach it wrong.
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.
158 if radio_type !=
"lr1121":
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 "
171 """Resolve the target firmware version from `source`/`target_version` without any network access.
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.
190KNOWN_BOOTLOADER_REQUIREMENTS = {
196LR1121_BOOTLOADER_2100 = 0x2100
197LR1121_BOOTLOADER_2101 = 0x2101
201 """Classify whether a `bootloader:` sub-block is safe given the outer `source:`'s target.
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).
212 required = KNOWN_BOOTLOADER_REQUIREMENTS.get(target_fw)
215 return "accept" if required == LR1121_BOOTLOADER_2101
else "hard_error"
220 """Result of fetch_and_verify(): a verified image ready to embed in the generated header."""
228 """Fetch, verify, and interpret the firmware image a YAML block points at.
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.
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
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.
248 effective_ref = ref
or parsed_ref
259 sidecar_text = fetch(url +
".md5").decode(
"utf-8")
260 except Lr1121FirmwareNotFoundError:
262 if sidecar_text
is not None:
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."
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}"
274 expected_hash = sidecar_hash
or normalized_checksum
276 data = fetch(url, expected_hash)
278 actual_hash = hashlib.md5(data).hexdigest()
279 if actual_hash != expected_hash:
280 raise Lr1121FirmwareError(f
"MD5 mismatch for {url}: expected {expected_hash}, got {actual_hash}")
validate_bootloader_reachability(radio_type, has_busy_pin, busy_pin_inverted)
validate_image_class(path, *, expect_loader)
resolve_raw_url(owner, repo, path, ref)
parse_version_from_filename(path)
classify_bootloader_upgrade_class(target_fw)
validate_image(data, url)
fetch_and_verify(source, ref, checksum_md5, target_version, fetch)
resolve_target_version(source, target_version)
parse_github_source(value)