Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
radio_lr1121_firmware_updater.h
Go to the documentation of this file.
1#pragma once
2
3/// @file radio_lr1121_firmware_updater.h
4/// @brief LR1121 bootloader-mode-*and*-loader-mode SPI transport, standalone from the running RadioDriver.
5/// @ingroup hioc_radio
6///
7/// Entirely wrapped in IOHOME_LR1121_FIRMWARE_UPDATE so it compiles to nothing unless a user
8/// opts in (components/home_io_control/__init__.py's `lr1121_firmware_update:` block sets the
9/// define). Bootloader-mode code is deliberately kept out of RadioDriver and
10/// RadioLR1121 — this class reimplements its own SPI transport against SpiAccess directly rather
11/// than sharing RadioLR1121's, because RadioLR1121::write_command_() takes a `uint8_t` length and
12/// WriteFlashEncrypted needs 4 + 256 = 260 parameter bytes in a single NSS cycle; that alone rules
13/// out sharing the transport, not just the class boundary.
14///
15/// The transport itself is mode-agnostic and always was: `update_bootloader()`/
16/// `verify_bootloader()`/`updater_reboot()` (gated behind IOHOME_LR1121_BOOTLOADER_UPDATE, the
17/// bootloader-*rewrite* feature — see ADR 0021) send the `0x81xx` opcode family
18/// while the chip is running the special *loader* transceiver firmware in NORMAL mode, not while
19/// it is in the bootloader — the `0x8xxx` prefix misleads. Only this class's previous users
20/// (bootloader-mode only) made it look bootloader-specific.
21///
22/// Takes the firmware image as a `(const uint32_t *, size_t)` parameter and never `#include`s the
23/// generated image header, so it stays host-testable with a small synthetic image and the ~64 KB
24/// blob stays out of every translation unit except the one that generated it.
25
26// IOHOME_LR1121_FIRMWARE_UPDATE is only visible after something pulls in esphome/core/defines.h
27// (ESPHome codegen's cg.add_define() lands there, not as a compiler -D flag) — the #include below
28// must run before the #ifdef check, not after, or this file silently compiles to an empty
29// translation unit in a real ESPHome build even though it works fine in host unit tests (whose
30// Makefile passes -DIOHOME_LR1121_FIRMWARE_UPDATE directly, masking the ordering bug).
31#include "radio_interface.h"
32#include "esphome/core/hal.h"
33
34#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
35
36#include <cstddef>
37#include <cstdint>
38#include <functional>
39
40namespace esphome {
41namespace home_io_control {
42
43/// @brief Bootloader-mode SPI opcodes, distinct from RadioLR1121's normal-mode
44/// opcode table — this class never touches that table or RadioLR1121 at all.
45inline constexpr uint16_t LR1121_UPDATER_CMD_GET_VERSION = 0x0101; ///< Same opcode in both normal and bootloader mode.
46inline constexpr uint16_t LR1121_UPDATER_CMD_ERASE_FLASH = 0x8000;
47inline constexpr uint16_t LR1121_UPDATER_CMD_WRITE_FLASH_ENCRYPTED = 0x8003;
48inline constexpr uint16_t LR1121_UPDATER_CMD_GET_HASH = 0x8004;
49inline constexpr uint16_t LR1121_UPDATER_CMD_REBOOT = 0x8005;
50
51/// @brief `type` byte GetVersion reports while running the bootloader (LR11XX_TYPE_PRODUCTION_MODE).
52inline constexpr uint8_t LR1121_UPDATER_BOOTLOADER_TYPE = 0xDF;
53
54/// @brief Bytes in a GetHash (0x8004) response (Semtech's `LR11XX_BL_HASH_LENGTH` /
55/// `lr11xx_bootloader_hash_t`). See read_hash()'s doc comment and the .cpp for what is (and is
56/// not) established about what this hash covers and what it should be compared against.
57inline constexpr size_t LR1121_UPDATER_HASH_LENGTH = 16;
58
59/// @brief Words per WriteFlashEncrypted chunk (LR11XX_FLASH_DATA_MAX_LENGTH_UINT32) — 256 bytes of
60/// payload, 262 bytes total with the 2-byte opcode and 4-byte offset in one NSS cycle.
61inline constexpr size_t LR1121_UPDATER_FLASH_CHUNK_WORDS = 64;
62
63/// @brief BUSY-wait timeout for ordinary bootloader commands (GetVersion, WriteFlashEncrypted
64/// chunks, Reboot) — same order of magnitude as RadioLR1121's normal-mode LR1121_BUSY_TIMEOUT_MS.
65inline constexpr uint32_t LR1121_UPDATER_BUSY_TIMEOUT_MS = 3000;
66/// @brief BUSY-wait timeout for EraseFlash, which holds BUSY high for the whole erase
67/// (seconds-scale) — the ordinary 3 s timeout is marginal for that.
68inline constexpr uint32_t LR1121_UPDATER_ERASE_BUSY_TIMEOUT_MS = 30000;
69
70#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
71/// @brief NORMAL-mode opcodes of the *loader* firmware (ADR 0021) — not a
72/// bootloader-mode command set, despite the `0x8xxx` prefix shared with LR1121_UPDATER_CMD_ERASE_FLASH
73/// etc. above. Gated separately from the rest of this file's opcodes because they are only ever
74/// meaningful once the bootloader-*rewrite* feature (a superset of the base transceiver-update
75/// feature) is compiled in.
76/// @brief `type` byte the *loader* firmware reports from a normal-mode GetVersion once it is
77/// running (i.e. after Stage 1b's reboot).
78///
79/// Undocumented by Semtech, whose own tool prints this byte and never checks it. Value observed on
80/// real hardware 2026-08-07 (LilyGO T3-S3, `lr1121_loader_2100.bin`): 0xDE. Note how close that is
81/// to LR1121_UPDATER_BOOTLOADER_TYPE (0xDF) — one bit — which is exactly why the Stage 1b
82/// checkpoint checks this byte positively rather than merely asserting "not 0xDF".
83///
84/// Safe to pin: the only configuration that can ever reach Stage 1b is loader 0x2100 on a chip
85/// whose bootloader is 0x2100 (lr1121_bootloader_upgrade_path()'s equality rule plus the
86/// requires-newer rule), so the loader image involved is always this exact one. If a future loader
87/// version ever becomes reachable, this check failing is the *safe* direction — it aborts before
88/// the irreversible write, leaving the bootloader untouched.
89inline constexpr uint8_t LR1121_UPDATER_LOADER_DEVICE_TYPE = 0xDE;
90
91inline constexpr uint16_t LR1121_UPDATER_CMD_UPDATE_BOOTLOADER = 0x8100;
92inline constexpr uint16_t LR1121_UPDATER_CMD_VERIFY_BOOTLOADER = 0x8101;
93inline constexpr uint16_t LR1121_UPDATER_CMD_UPDATER_REBOOT = 0x8102;
94
95/// @brief BUSY-wait timeout for 0x8100 UpdateBootloader, which — like EraseFlash — holds BUSY
96/// high for the duration of an internal flash operation. Real-hardware timing is not yet known
97/// — it is the one value in this feature never measured on real hardware — so this borrows
98/// LR1121_UPDATER_ERASE_BUSY_TIMEOUT_MS's budget until a real measurement narrows it.
99inline constexpr uint32_t LR1121_UPDATER_BOOTLOADER_UPDATE_BUSY_TIMEOUT_MS = LR1121_UPDATER_ERASE_BUSY_TIMEOUT_MS;
100
101/// @brief `command_status` field of Stat1, as reported by the loader firmware's status read.
102/// Values from Semtech's lr11xx_bootloader_updater_command_status_t.
103enum class Lr1121UpdaterCommandStatus : uint8_t {
104 FAIL = 0x00, ///< The last command was not executed.
105 PERR = 0x01, ///< The last command had a parameter error.
106 OK = 0x02, ///< The last command was executed.
107 DATA = 0x03, ///< The last command was executed and data is available.
108};
109
110/// @brief Decoded status read (Stat1/Stat2/IrqStatus) taken between UpdateBootloader and
111/// VerifyBootloader. Diagnostic: it says whether the chip *accepted* 0x8100 at all, which nothing
112/// else in the sequence reports directly -- a rejected command otherwise only surfaces as a failed
113/// verify, with no way to tell "rejected" from "written but bad".
114struct Lr1121UpdaterStatus {
115 Lr1121UpdaterCommandStatus command_status = Lr1121UpdaterCommandStatus::FAIL;
116 bool interrupt_active = false;
117 bool running_from_flash = false;
118 uint8_t chip_mode = 0;
119 uint8_t reset_status = 0;
120 uint32_t irq_status = 0;
121};
122
123/// @brief Decoded 0x8101 VerifyBootloader response (bootloader_updater_driver's
124/// lr11xx_bootloader_updater_verification_report_t — the only public specification for this
125/// layout; see verify_bootloader()'s .cpp comment for the exact bit mapping).
126struct Lr1121BootloaderVerification {
127 bool signature_verified = false;
128 bool version_verified = false;
129 bool use_case_verified = false;
130 bool version_major_verified = false;
131 bool version_minor_verified = false;
132 bool anti_rollback_verified = false; ///< Semantics undocumented by Semtech (ADR 0021); never a gate.
133 uint8_t use_case = 0;
134 uint8_t version_major = 0;
135 uint8_t version_minor = 0;
136
137 /// @return true only when all six checks in the report are true. Semtech's own gate
138 /// (lr11xx_bootloader_update.c:242-247) requires every one before declaring success;
139 /// this project does the same rather than second-guessing which bits matter.
140 [[nodiscard]] bool all_checks_passed() const {
141 return this->signature_verified && this->version_verified && this->use_case_verified &&
142 this->version_major_verified && this->version_minor_verified && this->anti_rollback_verified;
143 }
144};
145#endif // IOHOME_LR1121_BOOTLOADER_UPDATE
146
147/// @brief Bootloader-mode SPI transport and update sequence for the LR1121, used only while
148/// `IOHOME_LR1121_FIRMWARE_UPDATE` is compiled in.
149/// @ingroup hioc_radio
150class Lr1121FirmwareUpdater {
151 public:
152 /// @param spi SPI access, normally the hub itself (same wiring RadioLR1121 uses).
153 /// @param rst_pin Radio reset pin (same physical wire RadioLR1121 uses).
154 /// @param busy_pin Radio BUSY pin (same physical wire RadioLR1121 uses); temporarily driven as
155 /// a GPIO output during bootloader entry — see enter_bootloader().
156 Lr1121FirmwareUpdater(SpiAccess *spi, InternalGPIOPin *rst_pin, InternalGPIOPin *busy_pin);
157
158 /// @brief Read the transceiver firmware version while the chip is running normal (non-bootloader)
159 /// firmware — normal-mode GetVersion (0x0101).
160 /// @param device_type Output: chip identity byte (LR1121_DEVICE_TYPE == 0x03 when correct).
161 /// @param fw_major Output: firmware major version byte.
162 /// @param fw_minor Output: firmware minor version byte.
163 /// @return true on a completed SPI exchange; false on a BUSY timeout (chip unresponsive).
164 bool read_normal_version(uint8_t &device_type, uint8_t &fw_major, uint8_t &fw_minor);
165
166 /// @brief Reset the chip into bootloader mode and read its identity there.
167 ///
168 /// GPIO/reset sequence (no SPI opcode exists for this): drive BUSY as an
169 /// output LOW, pulse RST, wait 500 ms, return BUSY to input, wait a further 100 ms — then issue
170 /// a bootloader-mode GetVersion. This is a hardware reset: whatever normal-mode configuration
171 /// existed before this call is gone afterward, so the caller must then either run
172 /// radio_->init() (boot-time only) or App.safe_reboot() — never simply return (ADR 0020).
173 /// @param type Output: `type` byte from bootloader GetVersion (LR1121_UPDATER_BOOTLOADER_TYPE
174 /// == 0xDF when the chip is genuinely in its bootloader).
175 /// @param bootloader_version Output: bootloader version (major<<8 | minor).
176 /// @return true on a completed SPI exchange; false on a BUSY timeout.
177 bool enter_bootloader(uint8_t &type, uint16_t &bootloader_version);
178
179 /// @brief Read the bootloader-mode GetVersion response without running enter_bootloader()'s
180 /// RST-pulse/BUSY-strap entry sequence again.
181 ///
182 /// Factored out of enter_bootloader()'s tail so a caller already sitting in the chip's bootloader
183 /// (or, for the bootloader-rewrite feature, in the special *loader* firmware answering the same
184 /// opcode in normal mode) can re-read without a fresh hardware-reset excursion. Not gated behind
185 /// IOHOME_LR1121_BOOTLOADER_UPDATE: enter_bootloader() itself calls this unconditionally, so it
186 /// must exist in every build of this class.
187 /// @param type Output: `type` byte (LR1121_UPDATER_BOOTLOADER_TYPE == 0xDF while genuinely in
188 /// the bootloader).
189 /// @param bootloader_version Output: bootloader version (major<<8 | minor).
190 /// @return true on a completed SPI exchange; false on a BUSY timeout.
191 bool read_bootloader_version(uint8_t &type, uint16_t &bootloader_version);
192
193#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
194 /// @brief Request the bootloader rewrite (0x8100 UpdateBootloader).
195 ///
196 /// NORMAL-mode command of the *loader* firmware, not a bootloader-mode command — see this file's
197 /// header comment for why the `0x8xxx` prefix misleads here.
198 /// No parameters. Blocks on its own explicit BUSY wait before returning, unlike Semtech's
199 /// reference tool, which calls GetStatus exactly once and calls that "waiting for bootloader
200 /// update termination" — see the .cpp for why that gap is not repeated here.
201 /// @return true if the command was sent and BUSY cleared within
202 /// LR1121_UPDATER_BOOTLOADER_UPDATE_BUSY_TIMEOUT_MS; false otherwise.
203 bool update_bootloader();
204
205 /// @brief Read Stat1/Stat2/IrqStatus with a bare 6-byte SPI read and no opcode (Semtech's
206 /// `lr11xx_hal_direct_read` shape).
207 ///
208 /// Issued between UpdateBootloader and VerifyBootloader, where Semtech's reference tool issues
209 /// exactly the same transaction. Kept for two reasons: it keeps this project's wire traffic
210 /// byte-for-byte identical to the vendor's known-working sequence through the one stage that
211 /// cannot be tested without destroying a chip, and its `command_status` is the only direct
212 /// report of whether 0x8100 was accepted. Diagnostic only, like Semtech's own use of it — the
213 /// gate is verify_bootloader()'s six check bits.
214 /// @param status Output: decoded Stat1/Stat2/IrqStatus.
215 /// @return true on a completed SPI exchange; false on a BUSY timeout.
216 bool read_updater_status(Lr1121UpdaterStatus &status);
217
218 /// @brief Read the bootloader-update verification report (0x8101 VerifyBootloader). Same
219 /// normal-mode-of-the-loader caveat as update_bootloader().
220 /// @param report Output: the six check bits plus use-case/version bytes.
221 /// @return true on a completed SPI exchange; false on a BUSY timeout.
222 bool verify_bootloader(Lr1121BootloaderVerification &report);
223
224 /// @brief Reboot the loader firmware (0x8102 Reboot). Same normal-mode-of-the-loader caveat, and
225 /// the same wire encoding as reboot() (param 0x03/0x00) — kept as a separate method rather than
226 /// reused only because the opcode differs. "Success" after this call is the chip *staying* in
227 /// the bootloader and reporting the new version rather than booting — the freshly written
228 /// bootloader is expected to refuse the loader image, which was built for the old one —
229 /// callers must re-read with read_bootloader_version(), not assume a boot happened.
230 /// @param stay_in_bootloader Same meaning as reboot()'s parameter.
231 /// @return true if the command was sent; false on a BUSY timeout.
232 bool updater_reboot(bool stay_in_bootloader);
233#endif // IOHOME_LR1121_BOOTLOADER_UPDATE
234
235 /// @brief Erase the transceiver-firmware flash region. Bootloader-mode only. Never touches the
236 /// bootloader region itself, which is what keeps a failed write recoverable: bootloader-mode
237 /// entry is a GPIO strap, so it works regardless of what the transceiver region holds.
238 /// @return true if the command was sent and the chip reported BUSY-low again within
239 /// LR1121_UPDATER_ERASE_BUSY_TIMEOUT_MS; false otherwise.
240 bool erase_flash();
241
242 /// @brief Write a firmware image via chunked WriteFlashEncrypted, starting at flash offset 0.
243 /// @param image Big-endian 32-bit words, exactly as they appear in the published `.bin` file.
244 /// @param word_count Number of words in `image`.
245 /// @param on_progress Called after each chunk with (words_written_so_far, word_count); may be
246 /// an empty std::function, in which case it is not called.
247 /// @return true if every chunk was written; false on the first BUSY timeout, at which point the
248 /// image is left partially written — recoverable, since the bootloader region is
249 /// untouched and a retry can re-enter and rewrite.
250 bool write_image(const uint32_t *image, size_t word_count, const std::function<void(size_t, size_t)> &on_progress);
251
252 /// @brief Read the bootloader's hash of flash content (GetHash, 0x8004). Bootloader-mode only.
253 ///
254 /// INFORMATIONAL ONLY. Semtech's own reference updater (`lr11xx_update_firmware()`) never calls
255 /// this command, and its published header documents only that it "get[s] calculated hash of
256 /// flash content" — no algorithm, no stated hashed range (whole flash vs. just-written region),
257 /// and therefore no documented host-side expected value to compare against. This class exposes
258 /// the raw read so the caller can log it for the user to compare by hand; it deliberately does
259 /// not attempt an automated pass/fail comparison against anything, because that would mean
260 /// guessing a check we cannot currently verify — see the caller for how the value is used.
261 /// @param out Buffer for the hash bytes; must be at least LR1121_UPDATER_HASH_LENGTH long.
262 /// @param out_len Size of `out`. Caller-supplied (rather than a fixed-size array parameter) so
263 /// this header doesn't need to hardcode the same length twice.
264 /// @return true on a completed SPI exchange; false on a BUSY timeout, or if `out_len` is too
265 /// small (in which case nothing is sent).
266 bool read_hash(uint8_t *out, size_t out_len);
267
268 /// @brief Reboot the chip out of the bootloader command loop.
269 ///
270 /// Includes a settle delay after the command is sent, before returning — see the .cpp for the
271 /// BUSY race this closes and why the delay lives here rather than only at one call site.
272 /// @param stay_in_bootloader true to remain in the bootloader (param 0x03); false to boot the
273 /// (newly written, or still the pre-existing) transceiver firmware (param 0x00).
274 /// @return true if the command was sent; false on a BUSY timeout.
275 bool reboot(bool stay_in_bootloader);
276
277 private:
278 /// @brief Block until BUSY reads low, feeding the watchdog while waiting.
279 /// @param timeout_ms Per-call timeout — see LR1121_UPDATER_BUSY_TIMEOUT_MS /
280 /// LR1121_UPDATER_ERASE_BUSY_TIMEOUT_MS.
281 /// @return true if BUSY went low in time; false on timeout.
282 bool wait_busy_(uint32_t timeout_ms);
283 /// @brief Wait for BUSY, then write opcode + params in a single NSS cycle (write-only).
284 /// @param len Parameter length. `size_t`, not `uint8_t`: WriteFlashEncrypted's 4-byte offset
285 /// plus up to 256 bytes of payload is 260 bytes, which does not fit a byte length.
286 /// @return true on success; false if the preceding BUSY wait timed out.
287 bool write_command_(uint16_t opcode, const uint8_t *params, size_t len, uint32_t busy_timeout_ms);
288 /// @brief write_command_(), then wait BUSY again and clock out a Stat1 byte (discarded — this
289 /// class has no diagnostic use for it) followed by `out_len` response bytes.
290 /// @return true on success; false if either BUSY wait timed out.
291 bool read_command_(uint16_t opcode, const uint8_t *params, size_t params_len, uint8_t *out, size_t out_len,
292 uint32_t busy_timeout_ms);
293
294 SpiAccess *spi_;
295 InternalGPIOPin *rst_pin_;
296 InternalGPIOPin *busy_pin_;
297};
298
299} // namespace home_io_control
300} // namespace esphome
301
302#endif // IOHOME_LR1121_FIRMWARE_UPDATE
Radio abstraction layer for IO-Homecontrol.