Home IO Control
ESPHome add-on for IO-Homecontrol devices
Loading...
Searching...
No Matches
lr1121_firmware_update_controller.cpp
Go to the documentation of this file.
1// IOHOME_LR1121_FIRMWARE_UPDATE is only visible after something pulls in esphome/core/defines.h
2// (via this file's own header, which includes esphome/core/hal.h ahead of its own #ifdef) — these
3// #includes must run before the #ifdef check below, not after (see radio_lr1121_firmware_updater.h
4// for the fuller explanation).
6#include "log_helpers.h"
9
10#ifdef IOHOME_LR1121_FIRMWARE_UPDATE
11
12// Only ever generated when this define is set (components/home_io_control/lr1121_update_codegen.py), so this
13// #include must stay inside the guard above.
14#include "lr1121_firmware_update_image.h"
15
16#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
17// Only ever generated when the nested bootloader: sub-block is configured (lr1121_update_codegen.py's
18// _create_lr1121_bootloader_update()), so this #include must stay inside this guard too.
19#include "lr1121_bootloader_loader_image.h"
20#endif
21
22#include "esphome/core/application.h"
23
24#include <algorithm>
25#include <cinttypes>
26#include <cstdio>
27#include <string>
28
29/// @file lr1121_firmware_update_controller.cpp
30/// @brief LR1121 transceiver-firmware-update feature — orchestration collaborator.
31/// @ingroup hioc_hub
32///
33/// Owns the impure side of the feature: the boot-time bootloader-version excursion, the cached
34/// flash verdict, the two-press confirmation window, and the button-triggered flash sequence
35/// itself. The pure decision logic lives in lr1121_firmware_decisions.h; the bootloader-mode SPI
36/// transport lives in radio_lr1121_firmware_updater.h/.cpp. ADR 0020 and ADR 0021 record the
37/// design; the single most important rule they state is repeated here because it is easy to
38/// violate by accident:
39///
40/// After any bootloader excursion, the chip is unconfigured. Exactly one of two things must
41/// happen next: radio_->init() runs (the boot-time excursion below), or the ESP32 reboots
42/// (run_flash_sequence_(), every exit after calling enter_bootloader()). There is no third
43/// option — an early `return` on an error path would leave the radio silently dead: it would
44/// answer SPI, look initialized to the driver, and never work again.
45///
46/// This applies even when enter_bootloader() itself returns false. Its RST-pulse/BUSY-strap entry
47/// sequence (radio_lr1121_firmware_updater.cpp) runs unconditionally, before the GetVersion read
48/// that determines its return value — so a false return (e.g. that confirmatory read timing out)
49/// does not mean the chip is untouched. It means entry was attempted and cannot be confirmed,
50/// which is reason to reboot, not reason to skip rebooting.
51
52namespace esphome {
53namespace home_io_control {
54
55namespace {
56
57constexpr uint32_t LR1121_FLASH_CONFIRM_WINDOW_MS = 60 * 1000; ///< Q3: ~60s, a button two paces away.
58
59std::string format_lr1121_fw_version(uint16_t version) {
60 if (version == 0)
61 return "unknown";
62 char buf[8];
63 snprintf(buf, sizeof(buf), "%u.%u", static_cast<unsigned>(version >> 8), static_cast<unsigned>(version & 0xFF));
64 return buf;
65}
66
67std::string format_hex16(uint16_t value) {
68 char buf[8];
69 snprintf(buf, sizeof(buf), "0x%04X", value);
70 return buf;
71}
72
73std::string format_hex8(uint8_t value) {
74 char buf[6];
75 snprintf(buf, sizeof(buf), "0x%02X", value);
76 return buf;
77}
78
79/// "unknown" for the same sentinel reason format_lr1121_fw_version() uses -- 0 means the boot-time
80/// excursion never successfully read a bootloader version.
81std::string format_lr1121_bootloader_version(uint16_t version) {
82 return version == 0 ? "unknown" : format_hex16(version);
83}
84
85/// @brief Outcome of the post-bootloader-entry sanity read, factored out of run_flash_sequence_()
86/// to keep its cognitive complexity within clang-tidy's threshold.
87enum class Lr1121SanityResult {
88 OK, ///< type matches, and either the version matches what boot recorded, or boot
89 ///< recorded nothing and the freshly-read version positively identifies an LR1121.
90 WRONG_TYPE, ///< type != LR1121_UPDATER_BOOTLOADER_TYPE -- not in bootloader mode at all.
91 VERSION_MISMATCH, ///< type is fine, but the version boot recorded no longer matches.
92 WRONG_CHIP_FAMILY, ///< Boot recorded nothing to compare against, and the freshly-read version
93 ///< does not identify an LR1121 -- see lr1121_check_bootloader_sanity()'s
94 ///< comment for why type alone cannot catch this.
95};
96
97/// @brief When the boot-time excursion never read a bootloader version (`known` is
98/// false), there is nothing to compare `sanity_bootloader_version` against a prior reading, but it
99/// must still be checked against something: `sanity_type` (LR11XX_TYPE_PRODUCTION_MODE, 0xDF) is
100/// reported by an LR1120 or LR1110 too, so passing the type check alone does not prove this chip is
101/// an LR1121 -- only the bootloader *version* does that (lr1121_bootloader_is_lr1121()). Without
102/// this, the "boot-time read failed, adopt whatever bootloader-mode read we get now" recovery path
103/// would erase and overwrite an LR1120/LR1110 with an LR1121 image on nothing more than a byte both
104/// chips share. This is the last check before EraseFlash.
105Lr1121SanityResult lr1121_check_bootloader_sanity(bool known, uint16_t known_bootloader_version, uint8_t sanity_type,
106 uint16_t sanity_bootloader_version) {
107 if (sanity_type != LR1121_UPDATER_BOOTLOADER_TYPE)
108 return Lr1121SanityResult::WRONG_TYPE;
109 if (known) {
110 if (sanity_bootloader_version != known_bootloader_version)
111 return Lr1121SanityResult::VERSION_MISMATCH;
112 return Lr1121SanityResult::OK;
113 }
114 if (!lr1121_bootloader_is_lr1121(sanity_bootloader_version))
115 return Lr1121SanityResult::WRONG_CHIP_FAMILY;
116 return Lr1121SanityResult::OK;
117}
118
119/// @brief Human-readable reason for a sanity-check failure, for the abort log line in
120/// run_flash_sequence_(). Factored out so that line stays one statement regardless of how many
121/// distinct sanity failures exist.
122std::string lr1121_sanity_failure_reason(Lr1121SanityResult sanity, uint16_t sanity_bootloader_version) {
123 switch (sanity) {
124 case Lr1121SanityResult::WRONG_TYPE:
125 return "wrong type";
126 case Lr1121SanityResult::WRONG_CHIP_FAMILY:
127 return "bootloader version " + format_hex16(sanity_bootloader_version) + " identifies " +
128 lr1121_chip_family_for_bootloader(sanity_bootloader_version) + ", not an LR1121";
129 case Lr1121SanityResult::VERSION_MISMATCH:
130 default:
131 return "bootloader version changed since boot";
132 }
133}
134
135/// @brief Log the post-flash version read-back. target_fw == 0 ("unknown", an explicitly
136/// supported config when a renamed image's filename carries no version) must not be compared
137/// numerically -- doing so flags a "post-flash version is X, expected unknown" mismatch that is
138/// not real.
139void lr1121_log_post_flash_verify_result(uint16_t new_fw, uint16_t target_fw) {
140 if (target_fw == 0) {
141 ESP_LOGI(detail::TAG,
142 "LR1121 firmware update: now running %s; this build had no expected version to compare against",
143 format_lr1121_fw_version(new_fw).c_str());
144 } else if (new_fw == target_fw) {
145 ESP_LOGI(detail::TAG, "LR1121 firmware update: success -- now running %s",
146 format_lr1121_fw_version(new_fw).c_str());
147 } else {
148 ESP_LOGW(detail::TAG, "LR1121 firmware update: post-flash version is %s, expected %s",
149 format_lr1121_fw_version(new_fw).c_str(), format_lr1121_fw_version(target_fw).c_str());
150 }
151}
152
153/// @brief Read and log GetHash (0x8004) after a successful write, while still in bootloader mode.
154///
155/// INFORMATIONAL ONLY -- never a pass/fail gate, and it cannot become one.
156///
157/// GetHash (0x8004) is undocumented: it is absent from the LR1121 User Manual's bootloader command
158/// table (which lists 0x8000/0x8003/0x8005/0x800B/0x800C/0x800D), and Semtech's own reference
159/// updater defines the opcode but never calls it. No algorithm, no hashed range, no published
160/// expected value.
161///
162/// The obvious hypothesis -- 16 bytes is MD5-sized and every published image ships a `.bin.md5`
163/// sidecar, so perhaps this is the image's MD5 -- was DISPROVEN on real hardware 2026-08-07:
164/// flashing lr1121_transceiver_0103.bin produced 321388054ac482d5ae703d0ab5e7af09, while that
165/// image's sidecar reads 7e44170c815485559880592e7713407f.
166///
167/// That result has a likely structural explanation: WriteFlashEncrypted decrypts on the fly, so
168/// flash holds *plaintext* firmware while the `.bin` is ciphertext. A hash over flash contents can
169/// therefore never equal the file's MD5 -- reproducing it host-side would need the decrypted image,
170/// and the key is Semtech's. So this value is not merely unverified, it is unverifiable by this
171/// project, and no future code change should try to gate on it.
172///
173/// It is worth logging where it works: a stable fingerprint of what is actually in flash makes "do
174/// these two boards hold the same image?" answerable. But it does not work everywhere -- bootloader
175/// 0x2101 returns a fixed non-value (0x14 then fifteen zero bytes), observed twice on hardware
176/// 2026-08-07 across two code paths and two images, where 0x2100 returned a plausible digest. That
177/// case is detected and reported as "unavailable" rather than printed as though it identified
178/// anything. The real correctness check is the post-flash version read-back that follows, which is
179/// also what Semtech's reference relies on.
180///
181/// A failed read (BUSY timeout) is logged and otherwise ignored: this diagnostic must never block
182/// or fail an otherwise-successful write, and the established recovery messaging elsewhere in this
183/// sequence already covers what to do about a genuinely bad flash.
184void lr1121_log_post_write_hash(Lr1121FirmwareUpdater &updater) {
185 uint8_t hash[LR1121_UPDATER_HASH_LENGTH] = {0};
186 if (!updater.read_hash(hash, sizeof(hash))) {
187 ESP_LOGW(detail::TAG,
188 "LR1121 firmware update: could not read the flash fingerprint (BUSY timeout). Harmless -- it is "
189 "only an identifier, not a correctness check; the version check below is what confirms the flash.");
190 return;
191 }
192 // Bootloader 0x2101 answers GetHash with a fixed non-value (0x14 then fifteen zero bytes),
193 // observed twice on hardware across two different code paths and two different images, where
194 // 0x2100 returned a plausible digest. Printing that as an "identifier for the image" would be a
195 // lie: it is the same bytes whatever is flashed. Detect it generically rather than matching the
196 // exact constant -- a genuine 16-byte digest ending in fifteen zero bytes is not a case worth
197 // designing around.
198 const bool degenerate = std::all_of(hash + 1, hash + LR1121_UPDATER_HASH_LENGTH, [](uint8_t b) { return b == 0; });
199 if (degenerate) {
200 ESP_LOGI(detail::TAG,
201 "LR1121 firmware update: no flash fingerprint available on this bootloader (GetHash returned a "
202 "fixed non-value). Harmless -- it was only ever an identifier, never a correctness check; the "
203 "firmware version reported below is what confirms the flash worked.");
204 return;
205 }
206 char hex[LR1121_UPDATER_HASH_LENGTH * 2 + 1];
207 for (size_t i = 0; i < LR1121_UPDATER_HASH_LENGTH; i++)
208 snprintf(hex + i * 2, 3, "%02x", hash[i]);
209 ESP_LOGI(detail::TAG,
210 "LR1121 firmware update: flash fingerprint %s -- an identifier for the image now on the chip, useful "
211 "for comparing two boards. It is not the image's MD5 and cannot be checked against anything; the "
212 "firmware version reported below is what confirms the flash worked.",
213 hex);
214}
215
216/// @brief Erase, then chunk-write, one image -- with percentage progress logging prefixed by
217/// `stage_label`. Shared by run_flash_sequence_() (the single-image transceiver flash) and,
218/// under IOHOME_LR1121_BOOTLOADER_UPDATE, run_bootloader_upgrade_sequence_()'s two writes
219/// so the erase/write/progress shape exists in exactly one place rather than being duplicated
220/// per stage.
221/// @return true if both erase and write succeeded; false leaves the region partially written --
222/// the caller decides what that means for recovery.
223bool lr1121_erase_and_write_image_(Lr1121FirmwareUpdater &updater, const char *stage_label, const uint32_t *image,
224 size_t word_count, uint32_t &erase_elapsed_ms, uint32_t &write_elapsed_ms) {
225 ESP_LOGI(detail::TAG, "%s: erasing radio flash, this takes a few seconds...", stage_label);
226 const uint32_t erase_start_ms = millis();
227 if (!updater.erase_flash()) {
228 ESP_LOGE(detail::TAG, "%s: erase failed (BUSY timeout)", stage_label);
229 return false;
230 }
231 erase_elapsed_ms = millis() - erase_start_ms;
232
233 size_t last_logged_words = 0;
234 const size_t log_step = std::max<size_t>(word_count / 10, 1);
235 const uint32_t write_start_ms = millis();
236 const bool write_ok = updater.write_image(image, word_count, [&](size_t done, size_t total) {
237 // Percentage-based, not time-based: gives a consistent ~10 lines regardless of how long the
238 // write actually takes, since image sizes differ nearly 4x between published versions and the
239 // total duration is unknown until measured on real hardware.
240 if (done - last_logged_words < log_step && done != total)
241 return;
242 last_logged_words = done;
243 ESP_LOGI(detail::TAG, "%s: flashing %zu/%zu words (%u%%)", stage_label, done, total,
244 static_cast<unsigned>((done * 100) / total));
245 });
246 write_elapsed_ms = millis() - write_start_ms;
247 if (!write_ok) {
248 ESP_LOGE(detail::TAG, "%s: write failed (BUSY timeout) after %" PRIu32 " ms", stage_label, write_elapsed_ms);
249 return false;
250 }
251 ESP_LOGI(detail::TAG, "%s: erase took %" PRIu32 " ms, write took %" PRIu32 " ms", stage_label, erase_elapsed_ms,
252 write_elapsed_ms);
253 return true;
254}
255
256/// The specific reason behind a NEEDS_CONFIRMATION verdict. Re-derives the reason from the same
257/// inputs lr1121_flash_decision() used, in the same priority order that function checks them, so
258/// the two can never drift apart:
259/// 1. the boot-time bootloader read never completed;
260/// 2. the normal-mode read never completed (no installed-firmware read to compare against);
261/// 3. no target version could be determined at all;
262/// 4. the target is absent from this build's advisory compatibility table (unverified, not
263/// refused);
264/// 5. the installed version specifically is unknown (device_type known-good, firmware bytes
265/// were not);
266/// 6. target not newer than installed.
267std::string lr1121_needs_confirmation_reason(uint8_t device_type, uint16_t bootloader_version, uint16_t installed_fw,
268 uint16_t target_fw) {
269 if (bootloader_version == 0)
270 return "the bootloader version could not be read at boot, so chip identity and bootloader compatibility "
271 "cannot be verified";
272 if (device_type == 0)
273 return "the installed firmware version could not be read (radio failed to initialize, or the read itself "
274 "failed)";
275 if (target_fw == 0)
276 return "no target firmware version could be determined for the configured image";
277 if (lr1121_bootloader_supports_target(target_fw, bootloader_version) == BootloaderSupport::UNKNOWN_TARGET) {
278 return "target firmware " + format_lr1121_fw_version(target_fw) +
279 " is not in this build's known bootloader-compatibility table (unverified, not refused)";
280 }
281 if (installed_fw == 0)
282 return "the installed firmware version is unknown";
283 return "target firmware " + format_lr1121_fw_version(target_fw) + " is not newer than the installed " +
284 format_lr1121_fw_version(installed_fw);
285}
286
287} // namespace
288
289Lr1121FirmwareUpdateController::Lr1121FirmwareUpdateController(RadioDriver **radio, SpiAccess *spi,
290 InternalGPIOPin **rst_pin, InternalGPIOPin **busy_pin,
291 bool *busy,
292 BeginBlockingExcursionFn begin_blocking_excursion,
294 : lr1121_flash_verdict_(FlashDecision::NEEDS_CONFIRMATION),
295 radio_(radio),
296 spi_(spi),
297 rst_pin_(rst_pin),
298 busy_pin_(busy_pin),
299 busy_(busy),
300 begin_blocking_excursion_(std::move(begin_blocking_excursion)),
301 hub_(hub) {}
302
303void Lr1121FirmwareUpdateController::run_boot_time_bootloader_read() {
304 this->lr1121_firmware_updater_ =
305 new (std::nothrow) Lr1121FirmwareUpdater(this->spi_, *this->rst_pin_, *this->busy_pin_);
306 if (this->lr1121_firmware_updater_ == nullptr) {
307 ESP_LOGE(detail::TAG, "LR1121 firmware update: failed to allocate the updater; bootloader version unknown");
308 return;
309 }
310
311 uint8_t type = 0;
312 uint16_t bootloader_version = 0;
313 if (!this->lr1121_firmware_updater_->enter_bootloader(type, bootloader_version)) {
314 ESP_LOGW(detail::TAG, "LR1121 firmware update: could not read the bootloader version at boot (BUSY timeout) -- "
315 "bootloader version stays unknown until the next boot");
316 return;
317 }
318 this->lr1121_bootloader_chip_type_ = type;
319 this->lr1121_bootloader_version_ = bootloader_version;
320 this->lr1121_bootloader_version_known_ = true;
321
322 // Boot back into normal firmware so the radio_->init() called right after this finds the chip
323 // in the mode it expects. If this specific command fails to send, init()'s own hardware-level
324 // RST pulse forces the chip out of the bootloader anyway -- this is a courtesy, not a
325 // dependency, so a failure here is a warning, not cause to skip caching the version above.
326 if (!this->lr1121_firmware_updater_->reboot(false)) {
327 ESP_LOGW(detail::TAG, "LR1121 firmware update: reboot-out-of-bootloader command failed to send (BUSY timeout); the "
328 "upcoming radio init's own hardware reset will recover it");
329 }
330}
331
332void Lr1121FirmwareUpdateController::cache_flash_verdict() {
333 uint8_t device_type = 0;
334 uint16_t installed_fw = 0;
335 if (*this->radio_ != nullptr && this->lr1121_firmware_updater_ != nullptr) {
336 // Re-read via the updater's own transport rather than plumbing a getter through
337 // RadioDriver/RadioLR1121 for this one caller, so radio_lr1121.h gains zero new surface area.
338 // GetVersion is the same benign, side-effect-free read
339 // RadioLR1121::dump_debug() already issues at arbitrary times without disrupting RX.
340 uint8_t fw_major = 0, fw_minor = 0;
341 // device_type/fw_major/fw_minor are left at their zero-initialized values above on a failed
342 // read (BUSY timeout), which is exactly the "unknown" sentinel lr1121_flash_decision() expects.
343 if (this->lr1121_firmware_updater_->read_normal_version(device_type, fw_major, fw_minor))
344 installed_fw = (static_cast<uint16_t>(fw_major) << 8) | fw_minor;
345 }
346 this->lr1121_installed_device_type_ = device_type;
347 this->lr1121_installed_fw_ = installed_fw;
348 // device_type (normal-mode chip identity, layer 3) and lr1121_bootloader_chip_type_
349 // (bootloader-mode `type` byte, layer 4) are distinct inputs -- passing the bootloader-mode byte
350 // where device_type belongs made every real LR1121 fail its own chip-identity check.
351 this->lr1121_flash_verdict_ =
352 lr1121_flash_decision(device_type, this->lr1121_bootloader_chip_type_, this->lr1121_bootloader_version_,
353 installed_fw, LR1121_FIRMWARE_UPDATE_TARGET_VERSION, false);
354 this->lr1121_flash_verdict_known_ = true;
355}
356
357// Returns a complete verdict sentence with no assumption about what (if anything) is armed --
358// callers append their own context-appropriate follow-up (or none, at boot). Splitting the
359// "press again" wording out of here is what keeps the boot-time config dump honest: nothing is
360// armed at boot, so a sentence claiming otherwise would be false there.
361std::string Lr1121FirmwareUpdateController::describe_flash_verdict() const {
362 const uint16_t target = LR1121_FIRMWARE_UPDATE_TARGET_VERSION;
363 const std::string prefix = "Firmware update target: " + format_lr1121_fw_version(target) + " -- ";
364
365 switch (this->lr1121_flash_verdict_) {
366 case FlashDecision::REJECT_WRONG_CHIP: {
367 // lr1121_flash_decision() checks layer 4 (bootloader-mode identity) before layer 3
368 // (normal-mode identity), and never reaches either while bootloader_version is the
369 // "unknown" sentinel -- so re-checking layer 4 here fully determines which one rejected.
370 if (this->lr1121_bootloader_chip_type_ != LR1121_BOOTLOADER_TYPE_FOR_FIRMWARE_DECISIONS ||
371 !lr1121_bootloader_is_lr1121(this->lr1121_bootloader_version_)) {
372 return prefix + "CANNOT PROCEED: bootloader version " + format_hex16(this->lr1121_bootloader_version_) +
373 " identifies " + lr1121_chip_family_for_bootloader(this->lr1121_bootloader_version_) + ", not an LR1121";
374 }
375 return prefix + "CANNOT PROCEED: normal-mode chip identity byte " +
376 format_hex8(this->lr1121_installed_device_type_) + " identifies " +
377 lr1121_chip_family_for_device_type(this->lr1121_installed_device_type_) + ", not an LR1121";
378 }
379 case FlashDecision::REJECT_BOOTLOADER_TOO_OLD: {
380 // REJECT_BOOTLOADER_TOO_OLD covers a bootloader/target mismatch in EITHER direction (see
381 // lr1121_bootloader_mismatch_kind()'s doc comment) -- the "too new" direction only became
382 // reachable once a chip could actually be running 0x2101, and its message would be exactly
383 // backwards if it reused the "too old" wording below.
384 const uint16_t required = lr1121_required_bootloader_for(target);
385 if (lr1121_bootloader_mismatch_kind(target, this->lr1121_bootloader_version_) ==
386 BootloaderMismatch::TARGET_NEEDS_OLDER) {
387 return prefix + "CANNOT PROCEED: this chip's bootloader " + format_hex16(this->lr1121_bootloader_version_) +
388 " is newer than this firmware supports (needs " + format_hex16(required) +
389 ") -- there is no downgrade path";
390 }
391 std::string message = prefix + "CANNOT PROCEED: needs bootloader " + format_hex16(required) + ", this chip has " +
392 format_hex16(this->lr1121_bootloader_version_);
393#ifndef IOHOME_LR1121_BOOTLOADER_UPDATE
394 // Only meaningful advice when the feature isn't compiled in at all: a build with the
395 // bootloader: sub-block already configured knows exactly which upgrade path applies (or
396 // doesn't), and trigger()/debug_lines() already append their own path-specific suffix --
397 // appending this one unconditionally would tell the user to add a block they already added,
398 // or contradict the path-specific suffix outright.
399 message += " -- add a bootloader: sub-block to lr1121_firmware_update: to enable the (irreversible) upgrade "
400 "path";
401#endif
402 return message;
403 }
404 case FlashDecision::ALREADY_INSTALLED:
405 return prefix + "already running the configured firmware, nothing to do";
406 case FlashDecision::NEEDS_CONFIRMATION:
407 return prefix +
408 lr1121_needs_confirmation_reason(this->lr1121_installed_device_type_, this->lr1121_bootloader_version_,
409 this->lr1121_installed_fw_, target) +
410 " (bootloader version " + format_lr1121_bootloader_version(this->lr1121_bootloader_version_) + ")";
411 case FlashDecision::PROCEED:
412 default:
413 return prefix + "ready to flash (press \"Flash LR1121 Radio Firmware\")";
414 }
415}
416
417// The verdict line must still be included when the bootloader version is unknown: the verdict is
418// cached independently (cache_flash_verdict() runs in setup() regardless of whether the boot-time
419// excursion succeeded), so an early return here on a failed boot-time excursion would drop a
420// verdict line that is actually available.
421#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
422std::string Lr1121FirmwareUpdateController::describe_bootloader_refusal(BootloaderUpgradePath path) const {
423 // Deliberately NOT built by appending to describe_flash_verdict(): that function opens with
424 // "CANNOT PROCEED", which reads as final and then contradicts a suffix explaining that the
425 // rewrite is in fact available. Someone who has just pressed a button wants, in this order: what
426 // happened, why, and what to do next. A returned string (rather than a direct ESP_LOGE) is what
427 // makes these testable at all -- host builds compile the logging macros to no-ops.
428 const std::string target_text = format_lr1121_fw_version(LR1121_FIRMWARE_UPDATE_TARGET_VERSION);
429 const std::string chip_text = format_hex16(this->lr1121_bootloader_version_);
430 const std::string required_text = format_hex16(lr1121_required_bootloader_for(LR1121_FIRMWARE_UPDATE_TARGET_VERSION));
431 const std::string prefix = "LR1121 firmware update: nothing was done, the radio was not touched. ";
432
433 switch (path) {
434 case BootloaderUpgradePath::AVAILABLE:
435 return prefix + "Firmware " + target_text + " needs bootloader " + required_text + " and this chip has " +
436 chip_text +
437 ", so the bootloader has to be rewritten first. To do that, turn on the \"Allow LR1121 Bootloader "
438 "Rewrite (Irreversible)\" switch and press this button again. A bootloader rewrite cannot be undone.";
439 case BootloaderUpgradePath::BLOCKED_UNKNOWN_TARGET:
440 return prefix + "This build does not recognise firmware " + target_text +
441 ", so it cannot tell which bootloader that image needs. The bootloader rewrite stays disabled rather "
442 "than risk an irreversible write on a guess.";
443 case BootloaderUpgradePath::BLOCKED_BOOTLOADER_NEWER:
444 return prefix + "This chip's bootloader " + chip_text + " is already newer than firmware " + target_text +
445 " supports (that image needs " + required_text + "), and there is no way back to an older bootloader.";
446 case BootloaderUpgradePath::NOT_APPLICABLE:
447 default:
448 // Not reached from trigger(), which falls through to the ordinary transceiver-only refusal
449 // for NOT_APPLICABLE; present so the switch is total.
450 return this->describe_flash_verdict();
451 }
452}
453#endif // IOHOME_LR1121_BOOTLOADER_UPDATE
454
455std::vector<std::string> Lr1121FirmwareUpdateController::debug_lines() const {
456 std::vector<std::string> lines;
457 if (this->lr1121_bootloader_version_known_) {
458 lines.push_back("LR1121 bootloader version: " + format_hex16(this->lr1121_bootloader_version_));
459 } else {
460 lines.push_back("LR1121 firmware update: bootloader version could not be read at boot");
461 }
462 if (this->lr1121_flash_verdict_known_)
463 lines.push_back(this->describe_flash_verdict());
464#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
465 // Computed independently of the cached verdict/press logic --
466 // lr1121_bootloader_upgrade_path() already folds in every precondition (known bootloader,
467 // right chip family, loader match), so this is correct however it's called.
469 /*block_present=*/true, this->lr1121_bootloader_version_known_, this->lr1121_bootloader_version_,
470 LR1121_BOOTLOADER_LOADER_FW, LR1121_FIRMWARE_UPDATE_TARGET_VERSION);
471 if (upgrade_path == BootloaderUpgradePath::AVAILABLE) {
472 // Deliberately short: this prints on every boot. The switch is discoverable in Home Assistant
473 // and the full reasoning (why the rewrite exists, why there is no reason to rush it) lives in
474 // docs/lr1121-firmware.md and ADR 0021 -- a config dump is the wrong place to repeat it. What
475 // must survive the trim is the pair of versions and the fact that it cannot be undone.
476 lines.push_back("LR1121 bootloader rewrite: AVAILABLE -- needs bootloader " +
477 format_hex16(lr1121_required_bootloader_for(LR1121_FIRMWARE_UPDATE_TARGET_VERSION)) +
478 ", this chip has " + format_hex16(this->lr1121_bootloader_version_) +
479 ". A bootloader rewrite cannot be undone.");
480 } else if (upgrade_path == BootloaderUpgradePath::BLOCKED_UNKNOWN_TARGET) {
481 lines.push_back("LR1121 bootloader rewrite: configured, but inert -- this build does not know what bootloader the "
482 "configured target requires, so it will not gamble an irreversible write on it.");
483 } else if (upgrade_path == BootloaderUpgradePath::BLOCKED_BOOTLOADER_NEWER) {
484 lines.push_back("LR1121 bootloader rewrite: configured, but inert -- this chip's bootloader is already newer than "
485 "the configured target needs; there is no downgrade path.");
486 }
487#endif
488 return lines;
489}
490
491void Lr1121FirmwareUpdateController::dump_debug() const {
492 for (const auto &line : this->debug_lines())
493 ESP_LOGCONFIG(detail::TAG, " %s", line.c_str());
494}
495
496void Lr1121FirmwareUpdateController::arm_flash_confirmation_() {
497 this->lr1121_flash_confirmation_armed_ = true;
498 // Deliberately App.scheduler's self-keyed overload, not Component::set_timeout() (the
499 // key_extraction_responder.cpp idiom this would otherwise mirror). Component::set_timeout() records
500 // this component, and ESPHome's scheduler skips any scheduled item belonging to a *failed*
501 // component (Scheduler::should_skip_item_() -> is_item_failed_()). This method exists precisely
502 // for the recovery path where radio_->init() has failed and mark_failed() has already run -- if
503 // the callback were skipped there too, the confirmation window would never auto-disarm on
504 // exactly the board most likely to need a second press, degrading the two-press protection to
505 // "two presses ever". The self-keyed overload stores no Component, so it always fires. Do not
506 // "fix" this back to the named Component::set_timeout() idiom. The self key is the hub pointer
507 // (not this collaborator's `this`), so the recorded key is unchanged by the F5 move.
508 App.scheduler.set_timeout(static_cast<const void *>(this->hub_), LR1121_FLASH_CONFIRM_WINDOW_MS, [this]() {
509 // Guards against a stale timeout firing after a fresh press already consumed/re-armed the
510 // window — mirrors key_extraction_responder.cpp's KEY_EXTRACTION_AUTO_OFF_MS idiom.
511 if (!this->lr1121_flash_confirmation_armed_)
512 return;
513 this->lr1121_flash_confirmation_armed_ = false;
514 ESP_LOGI(detail::TAG, "LR1121 firmware update: confirmation window expired without a second press");
515 });
516}
517
518void Lr1121FirmwareUpdateController::trigger() {
519 // Guard 0: setup() deletes the driver and nulls radio_ when init() fails, but this button is a
520 // separate component whose press_action() still reaches the hub even after mark_failed().
521 // Deliberately still allow the attempt -- skipping only the standby call below -- since a radio
522 // that failed to initialize is exactly the case reflashing is meant to recover.
523 if (*this->radio_ == nullptr)
524 ESP_LOGW(detail::TAG, "LR1121 firmware update: radio_ is null (failed init); proceeding without standby");
525
526 // loop() guards every radio action behind `if (!this->busy_)`, so this is the same mechanism a
527 // blocking exchange already uses -- no new coordination. The safety here comes from ESPHome's
528 // cooperative single-threaded loop: an API-dispatched button press cannot land in the middle of
529 // a blocking exchange to begin with.
530 if (*this->busy_) {
531 ESP_LOGW(detail::TAG, "LR1121 firmware update: radio busy with another operation, ignoring press");
532 return;
533 }
534
535 if (this->lr1121_firmware_updater_ == nullptr || !this->lr1121_flash_verdict_known_) {
536 ESP_LOGE(detail::TAG, "LR1121 firmware update: no cached verdict available (setup() may have failed early)");
537 return;
538 }
539
540 const FlashDecision verdict = this->lr1121_flash_verdict_;
541
542 // REJECT_WRONG_CHIP never proceeds no matter what, including the bootloader-rewrite switch
543 // (hard rule 6) -- the verdict was already computed and logged at boot, so refusing here is a
544 // cached-verdict read, not a fresh bootloader entry.
545 if (verdict == FlashDecision::REJECT_WRONG_CHIP) {
546 ESP_LOGE(detail::TAG, "%s", this->describe_flash_verdict().c_str());
547 return;
548 }
549
550 if (verdict == FlashDecision::REJECT_BOOTLOADER_TOO_OLD) {
551#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
552 // The one place BootloaderUpgradePath::AVAILABLE can convert a hard rejection into the
553 // three-stage sequence -- see lr1121_bootloader_upgrade_path()'s doc comment for the full
554 // evaluation order. Every other outcome here still refuses without touching the chip.
556 /*block_present=*/true, this->lr1121_bootloader_version_known_, this->lr1121_bootloader_version_,
557 LR1121_BOOTLOADER_LOADER_FW, LR1121_FIRMWARE_UPDATE_TARGET_VERSION);
558 if (upgrade_path == BootloaderUpgradePath::AVAILABLE) {
559 if (!this->bootloader_rewrite_allowed_) {
560 ESP_LOGE(detail::TAG, "%s", this->describe_bootloader_refusal(upgrade_path).c_str());
561 return;
562 }
563 // The switch is read once, here, and replaces the two-press confirmation for this path --
564 // it is a permission, not something that stacks with the two-press window (hard rule 6's
565 // "never an override" applies the other way too: it only ever *adds* this one path).
566 ESP_LOGW(detail::TAG, "LR1121 bootloader rewrite: arming switch is on -- running the three-stage sequence now.");
567 this->run_bootloader_upgrade_sequence_();
568 return;
569 }
570 if (upgrade_path == BootloaderUpgradePath::BLOCKED_UNKNOWN_TARGET ||
571 upgrade_path == BootloaderUpgradePath::BLOCKED_BOOTLOADER_NEWER) {
572 ESP_LOGE(detail::TAG, "%s", this->describe_bootloader_refusal(upgrade_path).c_str());
573 return;
574 }
575 // upgrade_path == NOT_APPLICABLE: no upgrade possible/needed from this state (e.g. the
576 // boot-time bootloader read failed, or the configured loader doesn't match this bootloader) --
577 // fall through to the same refusal the transceiver-only build always gave.
578#endif
579 ESP_LOGE(detail::TAG, "%s", this->describe_flash_verdict().c_str());
580 return;
581 }
582
583 const bool proceeding = (verdict == FlashDecision::PROCEED) || this->lr1121_flash_confirmation_armed_;
584 if (!proceeding) {
585 // ALREADY_INSTALLED is the state a *successful* user spends the rest of the build's life
586 // in -- it must not read as a warning, and its "press again" follow-up talks about re-flashing
587 // rather than proceeding. Both facts are decided here, at the one call site where anything is
588 // actually about to be armed; describe_flash_verdict() itself stays neutral about it (see that
589 // function's comment) so the boot-time config dump never claims a window is armed.
590 const bool already_installed = (verdict == FlashDecision::ALREADY_INSTALLED);
591 const std::string confirm_suffix = " -- press \"Flash LR1121 Radio Firmware\" again within " +
592 std::to_string(LR1121_FLASH_CONFIRM_WINDOW_MS / 1000) + "s to " +
593 (already_installed ? "re-flash anyway" : "proceed anyway");
594 const std::string message = this->describe_flash_verdict() + confirm_suffix;
595 if (already_installed) {
596 ESP_LOGI(detail::TAG, "%s", message.c_str());
597 } else {
598 ESP_LOGW(detail::TAG, "%s", message.c_str());
599 }
600 this->arm_flash_confirmation_();
601 return;
602 }
603
604 this->lr1121_flash_confirmation_armed_ = false;
605 this->run_flash_sequence_();
606}
607
608void Lr1121FirmwareUpdateController::run_flash_sequence_() {
609 *this->busy_ = true;
610 // Raised for the duration of the flash so the log fills with the progress output below rather
611 // than component-blocking warnings. Component::warn_if_blocking_over_ is a centisecond uint8_t
612 // (max 2550ms) -- a flash can run far longer than that regardless, so this reduces warning
613 // spam, it cannot eliminate every warning for a longer block. Never restored -- every exit from
614 // this point on is App.safe_reboot(), which makes the saved value moot. (Injected: the hub's
615 // lambda sets the protected Component member.)
616 this->begin_blocking_excursion_();
617 if (*this->radio_ != nullptr)
618 (*this->radio_)->set_mode_standby(); // Never enter bootloader mode with RX armed.
619
620 // Every exit from here on is App.safe_reboot() -- see the file header's invariant. That includes
621 // the enter_bootloader() failure branch immediately below: its entry sequence runs unconditionally
622 // before the read that can time out, so a false return here does not mean the chip is untouched.
623 uint8_t sanity_type = 0;
624 uint16_t sanity_bootloader_version = 0;
625 if (!this->lr1121_firmware_updater_->enter_bootloader(sanity_type, sanity_bootloader_version)) {
626 ESP_LOGE(detail::TAG,
627 "LR1121 firmware update: bootloader entry could not be confirmed (BUSY timeout on the verification "
628 "read) -- the entry sequence itself already ran, so the chip may be unconfigured; rebooting to "
629 "recover it rather than risking a silently dead radio");
630 App.safe_reboot();
631 return;
632 }
633
634 const Lr1121SanityResult sanity = lr1121_check_bootloader_sanity(
635 this->lr1121_bootloader_version_known_, this->lr1121_bootloader_version_, sanity_type, sanity_bootloader_version);
636 if (sanity != Lr1121SanityResult::OK) {
637 const std::string sanity_reason = lr1121_sanity_failure_reason(sanity, sanity_bootloader_version);
638 ESP_LOGE(
639 detail::TAG,
640 "LR1121 firmware update: bootloader-entry sanity check failed (%s; read type=0x%02X bootloader=%s, "
641 "boot-time bootloader was %s) -- aborting before erasing anything",
642 sanity_reason.c_str(), sanity_type, format_hex16(sanity_bootloader_version).c_str(),
643 this->lr1121_bootloader_version_known_ ? format_hex16(this->lr1121_bootloader_version_).c_str() : "unknown");
644 this->lr1121_firmware_updater_->reboot(false);
645 App.safe_reboot();
646 return;
647 }
648 if (!this->lr1121_bootloader_version_known_) {
649 // Boot never got a reading, and a radio that failed to initialize is exactly the case
650 // reflashing is meant to recover, so this path stays open; the type check just
651 // above is all we could verify, so adopt this read for the rest of the attempt and future log
652 // lines rather than leaving lr1121_bootloader_version_ stuck at the "unknown" sentinel.
653 ESP_LOGI(detail::TAG,
654 "LR1121 firmware update: boot-time bootloader version was unknown; type check passed and bootloader "
655 "%s is now adopted",
656 format_hex16(sanity_bootloader_version).c_str());
657 this->lr1121_bootloader_chip_type_ = sanity_type;
658 this->lr1121_bootloader_version_ = sanity_bootloader_version;
659 this->lr1121_bootloader_version_known_ = true;
660 }
661
662 uint32_t erase_elapsed_ms = 0, write_elapsed_ms = 0;
663 if (!lr1121_erase_and_write_image_(*this->lr1121_firmware_updater_, "LR1121 firmware update",
664 LR1121_FIRMWARE_UPDATE_IMAGE, LR1121_FIRMWARE_UPDATE_IMAGE_WORDS, erase_elapsed_ms,
665 write_elapsed_ms)) {
666 ESP_LOGE(detail::TAG,
667 "LR1121 firmware update: the radio firmware is now incomplete. This is recoverable: after this "
668 "reboot, press the button again to re-flash.");
669 App.safe_reboot();
670 return;
671 }
672
673 // Read while still in bootloader mode, before rebooting into the newly written image -- see
674 // lr1121_log_post_write_hash()'s comment for why this is diagnostic-only.
675 lr1121_log_post_write_hash(*this->lr1121_firmware_updater_);
676
677 if (!this->lr1121_firmware_updater_->reboot(false)) {
678 ESP_LOGW(detail::TAG, "LR1121 firmware update: reboot-to-image command failed to send (BUSY timeout)");
679 } else {
680 uint8_t device_type = 0, fw_major = 0, fw_minor = 0;
681 if (this->lr1121_firmware_updater_->read_normal_version(device_type, fw_major, fw_minor)) {
682 const uint16_t new_fw = (static_cast<uint16_t>(fw_major) << 8) | fw_minor;
683 lr1121_log_post_flash_verify_result(new_fw, LR1121_FIRMWARE_UPDATE_TARGET_VERSION);
684 } else {
685 ESP_LOGW(detail::TAG, "LR1121 firmware update: could not read back the post-flash version (BUSY timeout)");
686 }
687 }
688
689 // A clean ESP32 restart is the post-flash path rather than re-running radio_->init() in place:
690 // by now init() has long since run and attached a DIO9 interrupt, so re-attachment, stale
691 // driver state and partial reconfiguration are all avoided at once.
692 App.safe_reboot();
693}
694
695#ifdef IOHOME_LR1121_BOOTLOADER_UPDATE
696
697void Lr1121FirmwareUpdateController::run_bootloader_upgrade_sequence_() {
698 *this->busy_ = true;
699 this->begin_blocking_excursion_();
700 if (*this->radio_ != nullptr)
701 (*this->radio_)->set_mode_standby();
702
703 ESP_LOGW(detail::TAG,
704 "LR1121 bootloader rewrite: starting the three-stage sequence, ~10s total. Mains power, not "
705 "battery -- do not interrupt power. Stage 2 has no recovery path in this project if power is lost.");
706
707 // --- Stage 1a: bootloader mode -- erase + write the loader image. Every exit from here on is
708 // App.safe_reboot() (see this method's doc comment in lr1121_firmware_update_controller.h). ---
709 uint8_t sanity_type = 0;
710 uint16_t sanity_bootloader_version = 0;
711 if (!this->lr1121_firmware_updater_->enter_bootloader(sanity_type, sanity_bootloader_version)) {
712 ESP_LOGE(detail::TAG,
713 "LR1121 bootloader rewrite: Stage 1a bootloader entry could not be confirmed (BUSY timeout) -- "
714 "the bootloader itself is untouched, this is recoverable: press the button again to retry.");
715 App.safe_reboot();
716 return;
717 }
718
719 const Lr1121SanityResult sanity = lr1121_check_bootloader_sanity(
720 this->lr1121_bootloader_version_known_, this->lr1121_bootloader_version_, sanity_type, sanity_bootloader_version);
721 if (sanity != Lr1121SanityResult::OK) {
722 const std::string sanity_reason = lr1121_sanity_failure_reason(sanity, sanity_bootloader_version);
723 ESP_LOGE(detail::TAG,
724 "LR1121 bootloader rewrite: Stage 1a sanity check failed (%s) -- aborting before erasing anything; "
725 "the bootloader is untouched, this is recoverable: press the button again to retry.",
726 sanity_reason.c_str());
727 this->lr1121_firmware_updater_->reboot(false);
728 App.safe_reboot();
729 return;
730 }
731 // No "adopt an unknown boot-time reading" branch here, unlike run_flash_sequence_()'s
732 // equivalent point: this function only ever runs when lr1121_bootloader_upgrade_path() returned
733 // AVAILABLE (trigger(), the only caller), and that function's rule 2 returns NOT_APPLICABLE
734 // whenever !lr1121_bootloader_version_known_ -- so an unknown bootloader can never reach this
735 // far. Not adding that branch here is deliberate: it would silently imply a reachable state that
736 // doesn't exist, right next to the irreversible write.
737
738 uint32_t erase_elapsed_ms = 0, write_elapsed_ms = 0;
739 if (!lr1121_erase_and_write_image_(
740 *this->lr1121_firmware_updater_, "LR1121 bootloader rewrite: Stage 1a (loader write)",
741 LR1121_BOOTLOADER_LOADER_IMAGE, LR1121_BOOTLOADER_LOADER_IMAGE_WORDS, erase_elapsed_ms, write_elapsed_ms)) {
742 ESP_LOGE(detail::TAG,
743 "LR1121 bootloader rewrite: Stage 1a failed -- the bootloader is untouched, this is recoverable: "
744 "press the button again to retry.");
745 App.safe_reboot();
746 return;
747 }
748
749 // --- Stage 1b: reboot into the loader; require it reports fw == 0x2100. Last checkpoint before
750 // the irreversible write -- a loader that did not land is caught here, not in Stage 2. ---
751 if (!this->lr1121_firmware_updater_->reboot(false)) {
752 ESP_LOGE(detail::TAG,
753 "LR1121 bootloader rewrite: Stage 1b reboot-into-loader command failed to send (BUSY timeout) -- "
754 "the bootloader is untouched, this is recoverable: press the button again to retry.");
755 App.safe_reboot();
756 return;
757 }
758 uint8_t loader_device_type = 0, loader_fw_major = 0, loader_fw_minor = 0;
759 // Read failure and version mismatch are deliberately not folded into one "fw == 0" check: this
760 // is the last checkpoint before the irreversible write, so a BUSY timeout (the chip reported
761 // nothing) must not be logged as though the chip positively reported firmware 0x0000.
762 if (!this->lr1121_firmware_updater_->read_normal_version(loader_device_type, loader_fw_major, loader_fw_minor)) {
763 ESP_LOGE(detail::TAG,
764 "LR1121 bootloader rewrite: Stage 1b checkpoint failed -- could not read the chip's firmware version "
765 "after the reboot (BUSY timeout). Aborting before the irreversible write; the bootloader is "
766 "untouched, this is recoverable: press the button again to retry.");
767 App.safe_reboot();
768 return;
769 }
770 // The version alone does NOT prove the loader is running: the loader image reports 0x2100, and
771 // so does the *bootloader* (LR1121_LOADER_2100 and LR1121_BOOTLOADER_2100 are the same number by
772 // design). reboot() only confirms the command was sent, never that the chip acted on it, so a
773 // chip that stayed in the bootloader would answer this read with exactly the bytes a successful
774 // loader boot produces -- and 0x8100 would then be sent to the bootloader, which does not
775 // implement it. `type` is the discriminator, and it is checked *positively* against the value the
776 // loader is known to report (LR1121_UPDATER_LOADER_DEVICE_TYPE, 0xDE, observed on hardware):
777 // 0xDE and the bootloader's 0xDF differ by one bit, so "anything but 0xDF" would accept a
778 // single-bit corruption of exactly the byte this check exists to trust.
779 if (loader_device_type != LR1121_UPDATER_LOADER_DEVICE_TYPE) {
780 const bool still_in_bootloader = loader_device_type == LR1121_UPDATER_BOOTLOADER_TYPE;
781 ESP_LOGE(detail::TAG,
782 "LR1121 bootloader rewrite: Stage 1b checkpoint failed -- chip reports type=0x%02X, expected the "
783 "loader's 0x%02X%s. The loader is not confirmed to be running, so 0x8100 must not be sent. "
784 "Aborting before the irreversible write; the bootloader is untouched, this is recoverable: press "
785 "the button again to retry.",
786 loader_device_type, LR1121_UPDATER_LOADER_DEVICE_TYPE,
787 still_in_bootloader ? " (0xDF means the chip never left bootloader mode)" : "");
788 App.safe_reboot();
789 return;
790 }
791 const uint16_t loader_running_fw = (static_cast<uint16_t>(loader_fw_major) << 8) | loader_fw_minor;
792 if (loader_running_fw != LR1121_LOADER_2100) {
793 ESP_LOGE(detail::TAG,
794 "LR1121 bootloader rewrite: Stage 1b checkpoint failed -- chip reports firmware %s after the "
795 "reboot, expected the loader's %s. Aborting before the irreversible write; the bootloader is "
796 "untouched, this is recoverable: press the button again to retry.",
797 format_lr1121_fw_version(loader_running_fw).c_str(), format_lr1121_fw_version(LR1121_LOADER_2100).c_str());
798 App.safe_reboot();
799 return;
800 }
801 ESP_LOGI(detail::TAG,
802 "LR1121 bootloader rewrite: Stage 1b checkpoint passed -- chip in transceiver mode: type=0x%02X fw=%s",
803 loader_device_type, format_lr1121_fw_version(loader_running_fw).c_str());
804
805 // --- Stage 2: normal mode, the loader is the running firmware. The one irreversible write. ---
806 ESP_LOGW(detail::TAG,
807 "LR1121 bootloader rewrite: Stage 2 -- rewriting the bootloader now. This step cannot be undone. Do "
808 "not interrupt power.");
809 if (!this->lr1121_firmware_updater_->update_bootloader()) {
810 ESP_LOGE(detail::TAG,
811 "LR1121 bootloader rewrite: Stage 2 UpdateBootloader timed out waiting for BUSY -- outcome "
812 "unknown, the bootloader may be mid-write. There is no recovery path in this project for this "
813 "failure. Rebooting.");
814 App.safe_reboot();
815 return;
816 }
817
818 // Semtech's reference tool issues exactly this read between UpdateBootloader and
819 // VerifyBootloader. Kept so the wire traffic through the one untestable stage stays identical to
820 // the vendor's known-working sequence, and because command_status is the only direct report of
821 // whether 0x8100 was accepted -- without it, a rejected command is indistinguishable from a
822 // completed-but-bad write. Diagnostic only (Semtech ignores the result too); the gate is the six
823 // check bits below.
824 Lr1121UpdaterStatus updater_status;
825 if (!this->lr1121_firmware_updater_->read_updater_status(updater_status)) {
826 ESP_LOGW(detail::TAG,
827 "LR1121 bootloader rewrite: Stage 2 status read timed out (BUSY) -- continuing to the verification "
828 "read, which is what actually decides the outcome");
829 } else if (updater_status.command_status != Lr1121UpdaterCommandStatus::OK &&
830 updater_status.command_status != Lr1121UpdaterCommandStatus::DATA) {
831 ESP_LOGE(detail::TAG,
832 "LR1121 bootloader rewrite: Stage 2 chip reports command_status=%u after UpdateBootloader (0=FAIL, "
833 "1=PERR) -- the chip did not accept 0x8100, which most likely means the bootloader was NOT "
834 "rewritten. The verification below decides; report this line if it appears.",
835 static_cast<unsigned>(updater_status.command_status));
836 } else {
837 ESP_LOGI(detail::TAG, "LR1121 bootloader rewrite: Stage 2 chip accepted UpdateBootloader (command_status=%u)",
838 static_cast<unsigned>(updater_status.command_status));
839 }
840
841 Lr1121BootloaderVerification verification;
842 if (!this->lr1121_firmware_updater_->verify_bootloader(verification)) {
843 ESP_LOGE(detail::TAG,
844 "LR1121 bootloader rewrite: Stage 2 VerifyBootloader read timed out (BUSY) after the write already "
845 "ran -- outcome unknown. There is no recovery path in this project for this failure. Rebooting.");
846 App.safe_reboot();
847 return;
848 }
849 if (!verification.all_checks_passed()) {
850 ESP_LOGE(detail::TAG,
851 "LR1121 bootloader rewrite: Stage 2 verification failed after the write already ran (signature=%d "
852 "version=%d use_case=%d version_major=%d version_minor=%d anti_rollback=%d) -- the write already "
853 "happened; do NOT retry Stage 2. There is no recovery path in this project for this failure. "
854 "Rebooting.",
855 verification.signature_verified, verification.version_verified, verification.use_case_verified,
856 verification.version_major_verified, verification.version_minor_verified,
857 verification.anti_rollback_verified);
858 App.safe_reboot();
859 return;
860 }
861
862 if (!this->lr1121_firmware_updater_->updater_reboot(false)) {
863 ESP_LOGE(detail::TAG,
864 "LR1121 bootloader rewrite: Stage 2 post-verify reboot command failed to send (BUSY timeout) -- "
865 "the write and verification both succeeded, but the chip's resulting state cannot be confirmed. "
866 "Rebooting the ESP32.");
867 App.safe_reboot();
868 return;
869 }
870 // Success is INVERTED here: the new bootloader is expected to refuse the loader image (built for
871 // the OLD bootloader) and stay in the bootloader rather than boot it (ADR 0021). A boot back
872 // into the loader here would mean the new bootloader is not actually running.
873 uint8_t post_update_type = 0;
874 uint16_t post_update_bootloader_version = 0;
875 const bool post_update_read_ok =
876 this->lr1121_firmware_updater_->read_bootloader_version(post_update_type, post_update_bootloader_version);
877 if (!post_update_read_ok || post_update_type != LR1121_UPDATER_BOOTLOADER_TYPE ||
878 post_update_bootloader_version != LR1121_BOOTLOADER_2101) {
879 ESP_LOGE(detail::TAG,
880 "LR1121 bootloader rewrite: Stage 2 succeeded but the chip is not behaving as expected afterward "
881 "(read_ok=%d type=0x%02X bootloader=%s; expected to stay in the bootloader reporting 0x2101) -- "
882 "the write already happened; this is NOT the recoverable kind of failure. If the chip still "
883 "answers a bootloader-mode GetVersion with a sane version, the strap works and a transceiver "
884 "image can be written for whichever bootloader it reports -- but do not auto-retry Stage 2. "
885 "Rebooting.",
886 post_update_read_ok, post_update_type, format_hex16(post_update_bootloader_version).c_str());
887 App.safe_reboot();
888 return;
889 }
890 this->lr1121_bootloader_chip_type_ = post_update_type;
891 this->lr1121_bootloader_version_ = post_update_bootloader_version;
892 ESP_LOGI(detail::TAG, "LR1121 bootloader rewrite: Stage 2 complete -- bootloader is now 0x2101.");
893
894 // --- Stage 3: re-enter the bootloader explicitly (do not rely on Stage 2's implicit state) and
895 // write the transceiver image. Full recovery from here: the bootloader is already 0x2101 and the
896 // transceiver image is already resident in ESP32 flash (the compile-time recovery-image rule). ---
897 uint8_t stage3_type = 0;
898 uint16_t stage3_bootloader_version = 0;
899 if (!this->lr1121_firmware_updater_->enter_bootloader(stage3_type, stage3_bootloader_version)) {
900 ESP_LOGE(detail::TAG,
901 "LR1121 bootloader rewrite: Stage 3 bootloader entry could not be confirmed (BUSY timeout) -- the "
902 "bootloader was already rewritten successfully in Stage 2, so this is recoverable: press the "
903 "ordinary flash button again (no switch needed) once power is stable.");
904 App.safe_reboot();
905 return;
906 }
907 if (stage3_type != LR1121_UPDATER_BOOTLOADER_TYPE || stage3_bootloader_version != LR1121_BOOTLOADER_2101) {
908 ESP_LOGE(detail::TAG,
909 "LR1121 bootloader rewrite: Stage 3 sanity check failed (type=0x%02X bootloader=%s, expected "
910 "0x2101) -- aborting before erasing the transceiver region. The bootloader was already rewritten "
911 "successfully in Stage 2; this is recoverable: press the ordinary flash button again.",
912 stage3_type, format_hex16(stage3_bootloader_version).c_str());
913 App.safe_reboot();
914 return;
915 }
916 this->lr1121_bootloader_chip_type_ = stage3_type;
917 this->lr1121_bootloader_version_ = stage3_bootloader_version;
918
919 if (!lr1121_erase_and_write_image_(
920 *this->lr1121_firmware_updater_, "LR1121 bootloader rewrite: Stage 3 (transceiver write)",
921 LR1121_FIRMWARE_UPDATE_IMAGE, LR1121_FIRMWARE_UPDATE_IMAGE_WORDS, erase_elapsed_ms, write_elapsed_ms)) {
922 ESP_LOGE(detail::TAG,
923 "LR1121 bootloader rewrite: Stage 3 failed -- the bootloader is already on 0x2101 (that part is "
924 "done and does not need to be repeated); this is recoverable: after this reboot, the ordinary "
925 "flash button (no switch needed) can retry the transceiver write.");
926 App.safe_reboot();
927 return;
928 }
929
930 lr1121_log_post_write_hash(*this->lr1121_firmware_updater_);
931
932 if (!this->lr1121_firmware_updater_->reboot(false)) {
933 ESP_LOGW(detail::TAG, "LR1121 bootloader rewrite: Stage 3 reboot-to-image command failed to send (BUSY timeout)");
934 } else {
935 uint8_t device_type = 0, fw_major = 0, fw_minor = 0;
936 if (this->lr1121_firmware_updater_->read_normal_version(device_type, fw_major, fw_minor)) {
937 const uint16_t new_fw = (static_cast<uint16_t>(fw_major) << 8) | fw_minor;
938 lr1121_log_post_flash_verify_result(new_fw, LR1121_FIRMWARE_UPDATE_TARGET_VERSION);
939 } else {
940 ESP_LOGW(detail::TAG, "LR1121 bootloader rewrite: could not read back the post-flash version (BUSY timeout)");
941 }
942 }
943
944 App.safe_reboot();
945}
946
947#endif // IOHOME_LR1121_BOOTLOADER_UPDATE
948
949} // namespace home_io_control
950} // namespace esphome
951
952#endif // IOHOME_LR1121_FIRMWARE_UPDATE
The main IO-Homecontrol component.
Definition hub_core.h:91
Abstract radio driver for IO-Homecontrol.
Interface for SPI bus access.
Hub-layer log tag and log/format helpers shared by the hub and its collaborators.
Pure decision logic for the LR1121 transceiver-firmware-update feature.
LR1121 transceiver-firmware-update feature — orchestration collaborator.
constexpr const char * TAG
Shared log tag for hub-level messages.
Definition log_helpers.h:31
constexpr uint16_t lr1121_required_bootloader_for(uint16_t target_fw)
Required bootloader for a known target firmware version.
constexpr BootloaderSupport lr1121_bootloader_supports_target(uint16_t target_fw, uint16_t bootloader_version)
Look up whether target_fw is known to require bootloader_version.
constexpr BootloaderUpgradePath lr1121_bootloader_upgrade_path(bool block_present, bool bootloader_version_known, uint16_t bootloader_version, uint16_t loader_fw, uint16_t target_fw)
Whether the three-stage bootloader upgrade is applicable for the current cached state.
BootloaderUpgradePath
Whether the three-stage bootloader-rewrite sequence (ADR 0021) is applicable, and if not,...
@ OK
Request built and transmitted (with or without replies).
constexpr const char * lr1121_chip_family_for_bootloader(uint16_t bootloader_version)
Human-readable chip family for a bootloader version that is not one of the two LR1121 values above,...
constexpr bool lr1121_bootloader_is_lr1121(uint16_t bootloader_version)
@ UNKNOWN_TARGET
target_fw does not appear in LR1121_KNOWN_BOOTLOADER_REQUIREMENTS at all.
constexpr FlashDecision lr1121_flash_decision(uint8_t device_type, uint8_t bootloader_chip_type, uint16_t bootloader_version, uint16_t installed_fw, uint16_t target_fw, bool already_confirmed)
The single decision point for whether/how to flash target_fw.
FlashDecision
Outcome of lr1121_flash_decision().
@ NEEDS_CONFIRMATION
Not unsafe, but not an unambiguous "yes" either — needs a second press.
std::function< void()> BeginBlockingExcursionFn
Raises the hub's "operation took a long time" warning threshold for a blocking radio excursion — writ...
Definition hub_hooks.h:39
constexpr BootloaderMismatch lr1121_bootloader_mismatch_kind(uint16_t target_fw, uint16_t bootloader_version)
Classify a bootloader/target mismatch by direction; see BootloaderMismatch.
constexpr const char * lr1121_chip_family_for_device_type(uint8_t device_type)
Human-readable chip family for a normal-mode device_type that is not the LR1121 value above,...
LR1121 bootloader-mode-*and*-loader-mode SPI transport, standalone from the running RadioDriver.