diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 6a5c9f8f..408fc47c 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -168,6 +168,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | | LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build; nRF52 installs in-place deltas | No | No | Yes | | LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible bootloader and completed update | No | No | Yes | +| LoRa OTA | [`ota rescue install `](ota_protocol.md#12-apply--bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | | LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | | LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | | LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | @@ -324,6 +325,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | | LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | | LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible completed update | No | No | Yes | No | No | Yes | Yes | +| LoRa OTA | [`ota rescue install `](ota_protocol.md#12-apply--bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | No | No | No | No | | LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | | LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes | | LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | No | No | Yes | Yes | diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index d45e9030..507ae514 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -237,7 +237,7 @@ nc 192.168.1.50 5002 ``` ```text -tempradio 909.950,250,7,5,120 +tempradio 909.950,250,5,5,120 ota status ``` @@ -260,7 +260,7 @@ picocom -b 115200 \ ``` ```text -tempradio 909.950,250,7,5,120 +tempradio 909.950,250,5,5,120 +++MESHCORE-TERM-STOP ``` diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index 7c29cbb2..a86b586b 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -179,15 +179,13 @@ and codec mask against the selected package. The default TempRadio tuple is: ```text -909.950,250,7,5,120 +909.950,250,5,5,120 ``` -The 250 kHz bandwidth, SF7, and CR5 combination is supported by every current -sub-GHz radio family used in USB Companion builds, including older SX127x -controllers (which do not support SF5). The frequency is only a North American -example: choose a legal frequency supported by every participating radio and -appropriate to your location. Pass the complete replacement tuple with -`--temp-radio`. +The test default is 250 kHz bandwidth, SF5, and CR5. The frequency is only a +North American example: choose a legal frequency supported by every +participating radio and appropriate to your location. Older radios that do not +support SF5 require a complete replacement tuple passed with `--temp-radio`. ## 4. Run an ESP32 update diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 880d0ffc..c9090494 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -679,6 +679,7 @@ ota status | st (or bare `ota`) plain-language: running fw, the one fetch ses ota ls | neighbors | nbrs | updates | n [page] paged updates (queries sources; rows arrive async via OTA_HAVE) ota get | pull | download <#|mid8> fetch a chosen mOTA (manual; works regardless of autofetch) ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader +ota rescue install internal-flash nRF52 only: recover from failed app-side EndF validation ota cancel | drop | stop drop the current fetch session (frees the slot) ota announce | adv serve self + send a beacon now ota self | id print this firmware's EndF (body/image size, base_hash) @@ -702,6 +703,15 @@ ota dev ... bring-up helpers (stage/recv/serve/verify) 2. re-checks `TRAILER`, `image_hash`, `approval == "APRV"`, and that the delta's `base_hash` equals the running firmware's `EndF.body_hash` (recomputed by scanning for `EndF` - never trust `bank_0_size`), 3. applies the in-place codec over the app region and boots only if the result hashes to `image_hash`. +- **nRF52 EndF rescue:** `ota rescue install ` is a pre-provisioned recovery path for an + internal-flash nRF52 application that still runs but cannot validate its own EndF identity. It refuses + when normal EndF validation succeeds, requires the operator hash to exactly equal the staged delta's + `base_hash`, requires the package `target_id` to match and its `hw_id` to pass the normal hardware gate, + and retains the normal payload and signature/allowlist gates. Approval only delegates the base decision: + OTAFIX independently + locates the physical EndF, hashes the running app, and compares that value with the package before its + first app write. A physically absent/corrupt EndF or wrong base therefore returns to the unchanged app; + it still requires USB recovery if that app does not already contain this command. - **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD and publishes its raw sector range in a checksummed handoff record outside the MBR partition. The matching bootloader reads the card without mounting FAT, supports either a full image or an in-place delta, diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 7d7d3640..c67d15f9 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -178,6 +178,18 @@ After it reboots, run `ota status` to confirm the new version. ### 5. If something goes wrong - A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again. +- An internal-flash **nRF52** that still runs but reports `no EndF` can use the pre-provisioned rescue path + if its physical EndF is intact and only app-side validation is failing. Fetch the exact in-place delta, + obtain its 16-hex-digit `base_hash` from the package metadata, then run: + + ```text + ota rescue install + ``` + + This is not a force option. It refuses a normally valid EndF, a different package hash, hardware or + target mismatch, corrupt payload, and invalid/untrusted signatures. The bootloader independently hashes + the running app and rejects a wrong base before writing the app. If the physical EndF is absent or this + command was not already in the running firmware, recover over USB. - If an **install** fails, the node won't boot a broken image - it lands in **recovery mode**: - **nRF52:** it appears as a USB drive; drag a known-good firmware `.uf2` for that exact board onto it to recover. @@ -301,6 +313,7 @@ that only contains what changed). You get them by: | Download update #1 for installation | `ota get 1 flash` | | Cancel a download | `ota cancel` | | Install a finished download | `ota install` | +| Recover app-side `no EndF` on internal nRF52 | `ota rescue install ` | | Turn on auto-download | `ota config autofetch any` | | Turn on auto-install (trusted only) | `ota config autoinstall trusted` | | Trust a signer | `ota key add ` | diff --git a/docs/rak3401_mota_chain.md b/docs/rak3401_mota_chain.md index 5161aa44..42582924 100644 --- a/docs/rak3401_mota_chain.md +++ b/docs/rak3401_mota_chain.md @@ -155,7 +155,7 @@ The tested Heltec key is Change the IP address for a different deployment. The source, controller, destination, and every intermediate relay must support -the temporary tuple `909.950,250,7,5`. Confirm that this frequency and +the temporary tuple `909.950,250,5,5`. Confirm that this frequency and bandwidth are legal at the deployment location. All nodes return to their ordinary radio after each step. diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index b3bd342c..16ed25fa 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -407,6 +407,10 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, const SignerAllowl bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "nRF52-only (ESP32 uses ota_apply_detools_mota)"); return false; } +bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, const uint8_t*, + uint32_t, ApplyState& st, char* msg) { + st = ApplyState(); strcpy(msg, "rescue is internal-flash nRF52-only"); return false; +} void ota_reboot_to_apply() { esp_restart(); } // boots the slot armed by ota_apply_detools_mota; no return @@ -438,8 +442,11 @@ uint8_t ota_bootloader_last_rc() { // bootloader's last in-place-app return (uint8_t)v; } -bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, - ApplyState& st, char* msg) { +static bool ota_apply_mota_nrf52_impl(const uint8_t* buf, uint32_t len, + const SignerAllowlist& allow, + const uint8_t* rescue_base_hash, + uint32_t local_target_id, + ApplyState& st, char* msg) { st = ApplyState(); MotaManifest m; if (!mota_parse(buf, len, m)) { strcpy(msg, "parse failed"); return false; } @@ -470,12 +477,31 @@ bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlis strcpy(msg, "payload hash mismatch (incomplete or corrupt .mota)"); return false; } - // 2) target firmware: the delta must be built against THIS running image (base_hash == our EndF body - // hash). The resulting image_hash is re-checked by the bootloader after the in-place decode -- a - // single-slot device cannot produce the target image to hash it before applying. + // 2) target firmware. Normally the delta must match the validated EndF body hash. The explicit rescue + // path exists only for a firmware whose normal EndF validation is broken: it requires the operator's + // exact package base hash and the package's target_id. It then delegates the actual running-image + // base check to the bootloader, which performs that check before its first application write. SelfFwInfo fi; - if (!ota_self_firmware(fi) || !fi.valid) { strcpy(msg, "cannot read running firmware (no EndF)"); return false; } - if (!m.base_hash || memcmp(m.base_hash, fi.body_hash, 8) != 0) { strcpy(msg, "not built for the running firmware (base mismatch)"); return false; } + const bool self_valid = ota_self_firmware(fi) && fi.valid; + if (rescue_base_hash) { + switch (ota_nrf52_rescue_gate(self_valid, m.base_hash, rescue_base_hash, + m.target_id, local_target_id)) { + case NRF52_RESCUE_SELF_VALID: + strcpy(msg, "EndF OK; use ota install"); return false; + case NRF52_RESCUE_BASE_MISSING: + case NRF52_RESCUE_BASE_MISMATCH: + strcpy(msg, "rescue base mismatch"); return false; + case NRF52_RESCUE_TARGET_MISMATCH: + strcpy(msg, "rescue target mismatch"); return false; + case NRF52_RESCUE_OK: + break; + } + } else { + if (!self_valid) { strcpy(msg, "cannot read running firmware (no EndF)"); return false; } + if (!m.base_hash || memcmp(m.base_hash, fi.body_hash, 8) != 0) { + strcpy(msg, "not built for the running firmware (base mismatch)"); return false; + } + } st.slot_ok = true; // 3) signature (only if the .mota is signed): valid Ed25519 AND signer in this device's allowlist @@ -493,11 +519,26 @@ bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlis // Approved. Do NOT reset here - return so the caller can deliver `msg` to the operator first; the // deferred ota_reboot_to_apply() (after the reply is sent) does the actual handoff to the bootloader. - sprintf(msg, "verified%s; applying - rebooting into bootloader once this reply is sent", - vr.is_signed ? " (signer trusted)" : " (unsigned)"); + if (rescue_base_hash) { + strcpy(msg, "rescue armed; bootloader checks base"); + } else { + sprintf(msg, "verified%s; applying - rebooting into bootloader once this reply is sent", + vr.is_signed ? " (signer trusted)" : " (unsigned)"); + } return true; } +bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, + ApplyState& st, char* msg) { + return ota_apply_mota_nrf52_impl(buf, len, allow, nullptr, 0, st, msg); +} + +bool ota_rescue_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, + const uint8_t operator_base_hash[8], uint32_t local_target_id, + ApplyState& st, char* msg) { + return ota_apply_mota_nrf52_impl(buf, len, allow, operator_base_hash, local_target_id, st, msg); +} + #if defined(OTA_SD_STORE) bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, const SignerAllowlist& allow, ApplyState& st, char* msg) { @@ -586,6 +627,10 @@ bool ota_apply_verify_slot(ApplyState&) { return false; } bool ota_apply_commit() { return false; } bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; } bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; } +bool ota_rescue_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, const uint8_t*, + uint32_t, ApplyState& st, char* msg) { + st = ApplyState(); strcpy(msg, "unsupported"); return false; +} void ota_reboot_to_apply() {} uint8_t ota_bootloader_last_rc() { return 0; } diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index 3d58c34e..c369b6a5 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -2,6 +2,7 @@ #include #include +#include #include "SignerAllowlist.h" // P6 apply (full-image, ESP32 A/B). The new image is delivered into the inactive OTA slot; the device @@ -23,6 +24,31 @@ struct ApplyState { uint8_t image_hash[32] = {0}; }; +// Policy gate for the nRF52 recovery-only apply path. This is intentionally separate from the normal +// EndF/base check so it cannot become an accidental fallback: the operator must invoke the rescue +// command with the exact base hash carried by the staged package, and the package must target this +// build. The bootloader still recomputes and checks the actual running-image base before writing it. +enum Nrf52RescueGate : uint8_t { + NRF52_RESCUE_OK = 0, + NRF52_RESCUE_SELF_VALID, + NRF52_RESCUE_BASE_MISSING, + NRF52_RESCUE_BASE_MISMATCH, + NRF52_RESCUE_TARGET_MISMATCH, +}; + +inline Nrf52RescueGate ota_nrf52_rescue_gate(bool self_valid, + const uint8_t* manifest_base_hash, + const uint8_t* operator_base_hash, + uint32_t manifest_target_id, + uint32_t local_target_id) { + if (self_valid) return NRF52_RESCUE_SELF_VALID; + if (!manifest_base_hash || !operator_base_hash) return NRF52_RESCUE_BASE_MISSING; + if (memcmp(manifest_base_hash, operator_base_hash, 8) != 0) return NRF52_RESCUE_BASE_MISMATCH; + if (!manifest_target_id || !local_target_id || manifest_target_id != local_target_id) + return NRF52_RESCUE_TARGET_MISMATCH; + return NRF52_RESCUE_OK; +} + bool ota_apply_slot_info(uint32_t* addr, uint32_t* size); // the inactive A/B slot bool ota_apply_set_manifest(const uint8_t* mf, uint32_t len, const SignerAllowlist& allow, ApplyState& st); // parse + verify signature @@ -50,6 +76,11 @@ bool ota_apply_detools_mota(const uint8_t* buf, uint32_t len, // Returns true (msg = "verified...") when approved, false (msg = the first failing gate) otherwise. bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow, ApplyState& st, char* msg); +// Recovery for failed app-side EndF validation. This is available only through the explicit +// `ota rescue install ` CLI command and never through normal or automatic installation. +bool ota_rescue_mota_nrf52(const uint8_t* buf, uint32_t len, + const SignerAllowlist& allow, const uint8_t operator_base_hash[8], + uint32_t local_target_id, ApplyState& st, char* msg); #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) class OtaStoreSdNrf52; bool ota_apply_mota_nrf52(OtaStoreSdNrf52& store, diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 0733a12d..b00c3970 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -131,6 +131,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board snprintf(reply, 160, "OTA seeder: status | stats | ls=find images | get <#> folder=capture | cancel | " "announce | folder | config. LoRa install is disabled."); +#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) + strcpy(reply, + "OTA: status | stats | ls | get | install | rescue install | cancel | announce | " + "self | folder | config | key"); #else snprintf(reply, 160, "OTA: status | stats=admin ids/hashes | ls=find updates | get <#>=download | install | cancel | " @@ -395,6 +399,32 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board #endif #endif + } else if (is_cmd(a, "rescue", &rest)) { + // Recovery-only nRF52 handoff for firmware whose normal EndF validation is broken. This command is + // deliberately not an alias or automatic fallback: the operator must name `install` and provide + // the exact 8-byte base hash carried by the already-fetched package. The bootloader independently + // hashes the running app and refuses a mismatch before writing any application flash. +#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && !defined(OTA_SEEDER_ONLY) + const char* hash_text = nullptr; + uint8_t operator_base_hash[8]; + if (!is_cmd(rest, "install", &hash_text) || + !mesh::Utils::fromHex(operator_base_hash, sizeof(operator_base_hash), hash_text)) { + strcpy(reply, "ERR usage: ota rescue install "); + return true; + } + if (c.manager.fetchState() != OtaManager::COMPLETE || c.fetch_store.staged_size() == 0) { + sprintf(reply, "ERR no complete update fetched (fetch=%c %u/%u)", + fstate_char(c.manager.fetchState()), (unsigned)c.manager.blocksHave(), + (unsigned)c.manager.blocksTotal()); + return true; + } + char m2[100]; + bool ok = c.apply_fetched_rescue(operator_base_hash, m2); + sprintf(reply, "%s | %s", ok ? "OK" : "ERR", m2); +#else + strcpy(reply, "ERR rescue requires an internal-flash nRF52 LoRa OTA build"); +#endif + } else if (is_cmd(a, "install|apply|applydelta", &rest)) { // Apply the fetched update. Destructive (reflashes + reboots) and GATED, not interactive (no "type // yes" round-trip - unreliable over LoRa): refuse unless the fetch is COMPLETE, then the apply path diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index bf064947..e86d173d 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -140,11 +140,28 @@ struct OtaContext { // so the deferred-reboot path (mesh loop) takes over. Caller ensures the fetch is COMPLETE. Shared by // manual `ota applydelta` and the auto-install path. bool apply_fetched(char* msg) { + return apply_fetched_impl(nullptr, msg); + } + + // Explicit recovery entry point for an internal-flash nRF52 whose normal EndF validation fails. This + // is never called by auto-install; the CLI requires the package's exact 8-byte base hash. + bool apply_fetched_rescue(const uint8_t operator_base_hash[8], char* msg) { + return apply_fetched_impl(operator_base_hash, msg); + } + + bool apply_fetched_impl(const uint8_t* rescue_base_hash, char* msg) { #if defined(OTA_SEEDER_ONLY) strncpy(msg, "refused: this build serves mOTA images but cannot install one", 96); msg[95] = 0; return false; #else +#if !defined(NRF52_PLATFORM) || defined(OTA_SD_STORE) + if (rescue_base_hash) { + strncpy(msg, "rescue is only for internal-flash nRF52 builds", 96); + msg[95] = 0; + return false; + } +#endif #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) if (sdCacheFetching()) { strncpy(msg, "refused: SD archive capture owns the OTA receive slot", 96); @@ -171,7 +188,12 @@ struct OtaContext { #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) ok = ota_apply_mota_nrf52(fetch_store, allow, apply_st, msg); #elif defined(NRF52_PLATFORM) - ok = ota_apply_mota_nrf52(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg); + if (rescue_base_hash) { + ok = ota_rescue_mota_nrf52(fetch_store.data(), fetch_store.staged_size(), allow, + rescue_base_hash, manager.target(), apply_st, msg); + } else { + ok = ota_apply_mota_nrf52(fetch_store.data(), fetch_store.staged_size(), allow, apply_st, msg); + } #elif defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE) ok = ota_apply_detools_mota(fetch_store, allow, apply_st, msg); #else diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index 3cf44576..30de9e17 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -12,6 +12,7 @@ #include "helpers/ota/OtaStore.h" #include "helpers/ota/OtaProtocol.h" #include "helpers/ota/OtaManager.h" +#include "helpers/ota/OtaApply.h" #include "mota_vectors.h" // auto-generated by tools/mota/gen_vectors.py extern "C" { @@ -64,6 +65,26 @@ TEST(OtaParse, ParsesReferenceContainer) { EXPECT_FALSE(m.is_approved()); } +TEST(OtaRescue, RequiresInvalidSelfExactBaseAndExactTarget) { + const uint8_t package_base[8] = {0x63, 0xd8, 0xdf, 0x63, 1, 2, 3, 4}; + const uint8_t operator_base[8] = {0x63, 0xd8, 0xdf, 0x63, 1, 2, 3, 4}; + const uint8_t wrong_base[8] = {0x63, 0xd8, 0xdf, 0x63, 1, 2, 3, 5}; + const uint32_t target_id = 0x2FA509C1u; + + EXPECT_EQ(ota_nrf52_rescue_gate(false, package_base, operator_base, + target_id, target_id), NRF52_RESCUE_OK); + EXPECT_EQ(ota_nrf52_rescue_gate(true, package_base, operator_base, + target_id, target_id), NRF52_RESCUE_SELF_VALID); + EXPECT_EQ(ota_nrf52_rescue_gate(false, nullptr, operator_base, + target_id, target_id), NRF52_RESCUE_BASE_MISSING); + EXPECT_EQ(ota_nrf52_rescue_gate(false, package_base, wrong_base, + target_id, target_id), NRF52_RESCUE_BASE_MISMATCH); + EXPECT_EQ(ota_nrf52_rescue_gate(false, package_base, operator_base, + target_id, target_id + 1), NRF52_RESCUE_TARGET_MISMATCH); + EXPECT_EQ(ota_nrf52_rescue_gate(false, package_base, operator_base, + target_id, 0), NRF52_RESCUE_TARGET_MISMATCH); +} + TEST(OtaParse, RejectsTampering) { MotaManifest m; // bad magic diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index 43244427..de1cca02 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -2066,7 +2066,7 @@ def build_parser() -> argparse.ArgumentParser: help="optional relay, ordered farthest-to-nearest; repeat as needed", ) parser.add_argument( - "--temp-radio", default="909.950,250,7,5,120", + "--temp-radio", default="909.950,250,5,5,120", help="frequency,bw,sf,cr,minutes", ) parser.add_argument( diff --git a/tools/lora_ota/rak3401_mota_chain.py b/tools/lora_ota/rak3401_mota_chain.py index 6c30b360..59ab8228 100755 --- a/tools/lora_ota/rak3401_mota_chain.py +++ b/tools/lora_ota/rak3401_mota_chain.py @@ -866,7 +866,7 @@ def build_parser() -> argparse.ArgumentParser: metavar="NAME[=PASSWORD]", help="intermediate relay, farthest-to-nearest; repeat for each relay", ) - parser.add_argument("--temp-radio", default="909.950,250,7,5,120") + parser.add_argument("--temp-radio", default="909.950,250,5,5,120") parser.add_argument( "--ota-hops", type=int, diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index ff1d99af..f0b633e2 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -141,11 +141,17 @@ class FormatTests(unittest.TestCase): def test_temp_radio_accepts_only_cli_bandwidths(self) -> None: self.assertEqual( - ota.parse_temp_radio("909.950,250,7,5,120"), - (909.95, 250.0, 7, 5, 120), + ota.parse_temp_radio("909.950,250,5,5,120"), + (909.95, 250.0, 5, 5, 120), ) with self.assertRaisesRegex(argparse.ArgumentTypeError, "bandwidth must be"): - ota.parse_temp_radio("909.950,200,7,5,120") + ota.parse_temp_radio("909.950,200,5,5,120") + + def test_ota_runners_default_to_sf5_and_250_khz(self) -> None: + generic = ota.build_parser().parse_args(["release.mota", "remote"]) + chain = rak_chain.build_parser().parse_args([]) + self.assertEqual(generic.temp_radio, "909.950,250,5,5,120") + self.assertEqual(chain.temp_radio, "909.950,250,5,5,120") def test_offline_sd_nrf52_does_not_require_a_base_hash(self) -> None: parser = ota.build_parser() @@ -284,14 +290,14 @@ class SourceCliTests(unittest.TestCase): ) with mock.patch.object(ota, "run_checked", return_value=completed) as run: output = ota.source_cli_command( - args, "tempradio 909.95,250,7,5,120" + args, "tempradio 909.95,250,5,5,120" ) wire_command = run.call_args.args[0][-1] self.assertEqual( wire_command, "+++MESHCORE-TERM-START\r" - "tempradio 909.95,250,7,5,120\r" + "tempradio 909.95,250,5,5,120\r" "+++MESHCORE-TERM-STOP", ) self.assertIn("OK - temp params", output) @@ -661,13 +667,13 @@ class ReliabilityTests(unittest.TestCase): controller = object.__new__(ota.Controller) controller._run = lambda *_args: [{}] controller.get_radio = lambda: ota.RadioSettings(915, 250, 7, 5, False) - requested = ota.RadioSettings(909.95, 250, 7, 5, False) + requested = ota.RadioSettings(909.95, 250, 5, 5, False) with self.assertRaisesRegex(ota.OtaError, "read back"): controller.set_radio(requested, "set radio") def test_lost_target_temp_reply_is_resolved_on_temporary_channel(self) -> None: normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) - temporary = ota.RadioSettings(909.95, 250.0, 7, 5, False) + temporary = ota.RadioSettings(909.95, 250.0, 5, 5, False) class Controller: def __init__(self) -> None: @@ -689,19 +695,19 @@ class ReliabilityTests(unittest.TestCase): ota.arm_target_temp_radio( controller, argparse.Namespace(target="remote"), - "tempradio 909.95,250,7,5,120", + "tempradio 909.95,250,5,5,120", temporary, normal, ) self.assertEqual( controller.commands, - ["tempradio 909.95,250,7,5,120", "ota self"], + ["tempradio 909.95,250,5,5,120", "ota self"], ) self.assertEqual(controller.radios, [temporary, normal]) def test_ambiguous_target_temp_probe_is_not_replayed(self) -> None: normal = ota.RadioSettings(910.525, 62.5, 7, 5, False) - temporary = ota.RadioSettings(909.95, 250.0, 7, 5, False) + temporary = ota.RadioSettings(909.95, 250.0, 5, 5, False) class Controller: def __init__(self) -> None: @@ -722,13 +728,13 @@ class ReliabilityTests(unittest.TestCase): ota.arm_target_temp_radio( controller, argparse.Namespace(target="remote"), - "tempradio 909.95,250,7,5,120", + "tempradio 909.95,250,5,5,120", temporary, normal, ) self.assertEqual( controller.commands, - ["tempradio 909.95,250,7,5,120", "ota self"], + ["tempradio 909.95,250,5,5,120", "ota self"], ) self.assertEqual(controller.radios, [temporary, normal]) @@ -759,15 +765,15 @@ class ReliabilityTests(unittest.TestCase): controller = Controller() args = argparse.Namespace( - target="remote", temp_values=(909.95, 250.0, 7, 5, 120) + target="remote", temp_values=(909.95, 250.0, 5, 5, 120) ) with mock.patch.object(ota.time, "sleep"): self.assertTrue(ota.request_install(controller, args, package)) self.assertEqual( controller.commands, [ - "ota status", "tempradio 909.95,250,7,5,3", "ota install", - "ota status", "tempradio 909.95,250,7,5,3", "ota install", + "ota status", "tempradio 909.95,250,5,5,3", "ota install", + "ota status", "tempradio 909.95,250,5,5,3", "ota install", ], ) @@ -794,7 +800,7 @@ class ReliabilityTests(unittest.TestCase): controller = Controller() args = argparse.Namespace( target="remote", - temp_values=(909.95, 250.0, 7, 5, 120), + temp_values=(909.95, 250.0, 5, 5, 120), require_system_watchdog_off=True, ) self.assertTrue(ota.request_install(controller, args, package)) @@ -802,7 +808,7 @@ class ReliabilityTests(unittest.TestCase): controller.commands, [ "ota status", - "tempradio 909.95,250,7,5,3", + "tempradio 909.95,250,5,5,3", "get system.watchdog", "ota install", ], @@ -830,7 +836,7 @@ class ReliabilityTests(unittest.TestCase): controller = Controller() args = argparse.Namespace( target="remote", - temp_values=(909.95, 250.0, 7, 5, 120), + temp_values=(909.95, 250.0, 5, 5, 120), require_system_watchdog_off=True, ) with self.assertRaisesRegex(ota.OtaError, "must report `> off`"): @@ -939,7 +945,7 @@ class ReliabilityTests(unittest.TestCase): "release.mota", "remote", "--controller-serial", "/dev/controller", "--source-serial", "/dev/source", - "--temp-radio", "909.950,250,7,5,114", + "--temp-radio", "909.950,250,5,5,114", ]) with ( contextlib.redirect_stderr(io.StringIO()), @@ -963,15 +969,15 @@ class ReliabilityTests(unittest.TestCase): args = argparse.Namespace( target="remote", relay_values=[("relay", "relay-secret")], - temp_values=(909.95, 250.0, 7, 5, 120), + temp_values=(909.95, 250.0, 5, 5, 120), ) ota.shorten_target_temp_window(controller, args) ota.shorten_relay_temp_windows(controller, args) self.assertEqual( controller.commands, [ - ("remote", "tempradio 909.95,250,7,5,1", None), - ("relay", "tempradio 909.95,250,7,5,1", "relay-secret"), + ("remote", "tempradio 909.95,250,5,5,1", None), + ("relay", "tempradio 909.95,250,5,5,1", "relay-secret"), ], ) @@ -979,14 +985,14 @@ class ReliabilityTests(unittest.TestCase): args = argparse.Namespace( source_already_temp=False, source_shares_controller=False, - temp_values=(909.95, 250.0, 7, 5, 120), + temp_values=(909.95, 250.0, 5, 5, 120), ) with mock.patch.object( ota, "source_cli_command", return_value="OK - temp params for 1 mins" ) as source_command: self.assertTrue(ota.shorten_source_temp_window(args)) source_command.assert_called_once_with( - args, "tempradio 909.95,250,7,5,1", check=True + args, "tempradio 909.95,250,5,5,1", check=True ) args.source_already_temp = True @@ -998,7 +1004,7 @@ class ReliabilityTests(unittest.TestCase): args = argparse.Namespace( source_already_temp=False, source_shares_controller=True, - temp_values=(909.95, 250.0, 7, 5, 120), + temp_values=(909.95, 250.0, 5, 5, 120), ) with ( mock.patch.object(