Home IO Control
ESPHome add-on for IO-Homecontrol devices
Toggle main menu visibility
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; lr1121_update_codegen.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
19
import
hashlib
20
import
re
21
from
dataclasses
import
dataclass
22
23
24
class
Lr1121FirmwareError
(Exception):
25
"""A configured firmware source is malformed, unverifiable, or fails validation."""
26
27
28
class
Lr1121FirmwareNotFoundError
(
Lr1121FirmwareError
):
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
48
def
parse_github_source
(value):
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
:
58
raise
Lr1121FirmwareError
(
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
64
def
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
69
def
parse_md5_sidecar
(text):
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
85
def
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. Without this guard, a
91
loader/modem source would pass validation and be routed through the two-press UNKNOWN_TARGET
92
confirmation, leaving the radio running a non-transceiver image after the flash -- SPI still
93
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:
102
raise
Lr1121FirmwareError
(
103
f
"bootloader: source must be a bootloader loader image (filename containing '_loader_'), got {path!r}"
104
)
105
return
106
if
is_loader:
107
raise
Lr1121FirmwareError
(
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:
112
raise
Lr1121FirmwareError
(
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
118
def
parse_version_from_filename
(path):
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
130
def
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:
144
raise
Lr1121FirmwareError
(
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
151
def
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 lr1121_update_codegen.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; lr1121_update_codegen.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:
163
raise
Lr1121FirmwareError
(
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
170
def
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.
190
KNOWN_BOOTLOADER_REQUIREMENTS = {
191
0x0101: 0x2100,
192
0x0102: 0x2100,
193
0x0103: 0x2100,
194
0x0104: 0x2101,
195
}
196
LR1121_BOOTLOADER_2100 = 0x2100
197
LR1121_BOOTLOADER_2101 = 0x2101
198
199
200
def
classify_bootloader_upgrade_class
(target_fw):
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
219
class
FirmwareImage
:
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
227
def
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 lr1121_update_codegen.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
:
267
raise
Lr1121FirmwareError
(
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:
271
raise
Lr1121FirmwareError
(
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)
home_io_control.lr1121_firmware.FirmwareImage
Definition
lr1121_firmware.py:219
home_io_control.lr1121_firmware.Lr1121FirmwareError
Definition
lr1121_firmware.py:24
home_io_control.lr1121_firmware.Lr1121FirmwareNotFoundError
Definition
lr1121_firmware.py:28
home_io_control.lr1121_firmware.parse_version_from_filename
parse_version_from_filename(path)
Definition
lr1121_firmware.py:118
home_io_control.lr1121_firmware.parse_md5_sidecar
parse_md5_sidecar(text)
Definition
lr1121_firmware.py:69
home_io_control.lr1121_firmware.parse_github_source
parse_github_source(value)
Definition
lr1121_firmware.py:48
home_io_control.lr1121_firmware.validate_image_class
validate_image_class(path, *, expect_loader)
Definition
lr1121_firmware.py:85
home_io_control.lr1121_firmware.classify_bootloader_upgrade_class
classify_bootloader_upgrade_class(target_fw)
Definition
lr1121_firmware.py:200
home_io_control.lr1121_firmware.validate_bootloader_reachability
validate_bootloader_reachability(radio_type, has_busy_pin, busy_pin_inverted)
Definition
lr1121_firmware.py:151
home_io_control.lr1121_firmware.validate_image
validate_image(data, url)
Definition
lr1121_firmware.py:130
home_io_control.lr1121_firmware.fetch_and_verify
fetch_and_verify(source, ref, checksum_md5, target_version, fetch)
Definition
lr1121_firmware.py:227
home_io_control.lr1121_firmware.resolve_raw_url
resolve_raw_url(owner, repo, path, ref)
Definition
lr1121_firmware.py:64
home_io_control.lr1121_firmware.resolve_target_version
resolve_target_version(source, target_version)
Definition
lr1121_firmware.py:170
components
home_io_control
lr1121_firmware.py
Generated by
1.18.0