diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 91422a89..8bb7bf6e 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -169,6 +169,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | 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 | +| LoRa OTA | [`ota qspi`; `ota storage`](ota_protocol.md#11-cli-surface-otaclicpp) | nRF52 build with raw-QSPI OTA staging | Feature | Feature | Feature | | LoRa OTA | [`ota folder`; `ota fold`](ota_protocol.md#11-cli-surface-otaclicpp) | `on` needs compiled folder transport | No | No | Feature | | LoRa OTA | [`ota config`; `ota cfg`; `ota set`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | | LoRa OTA | [`ota key`; `ota keys`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | @@ -327,6 +328,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without | LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | | LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | | LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | Yes | Yes | +| LoRa OTA | [`ota qspi`; `ota storage`](ota_protocol.md#11-cli-surface-otaclicpp) | nRF52 build with raw-QSPI OTA staging | Feature | Feature | Feature | No | No | | LoRa OTA | [`ota folder`; `ota fold`](ota_protocol.md#11-cli-surface-otaclicpp) | `on` needs compiled serial or TCP folder transport | No | No | Feature | Feature | Feature | | LoRa OTA | [`ota config`; `ota cfg`; `ota set`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | | LoRa OTA | [`ota key`; `ota keys`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | Yes | Yes | diff --git a/docs/ota_nrf52_qspi.md b/docs/ota_nrf52_qspi.md index ea4ac869..f6cbf924 100644 --- a/docs/ota_nrf52_qspi.md +++ b/docs/ota_nrf52_qspi.md @@ -108,14 +108,16 @@ After installing the repeater application, check: get bootloader.ver ota self ota status +ota qspi ``` -A ready target reports all of the following: +A ready RAK15001 target reports all of the following: ```text QSPI store:2048K bootloader: QSPI apply OK bl:QSPI +QSPI jedec=C84015 size=2048K sr1=00 stage=jedec ``` Other supported boards can report a capacity different from 2048K; the @@ -123,6 +125,11 @@ RAK15001 target must report exactly 2048K. `QSPI store:ERR 0K`, `NO QSPI`, or `bl:NO-QSPI` means the flash wiring, flash power, or bootloader does not match. Do not start an install in that state. +`ota qspi` is a read-only diagnostic probe available on QSPI OTA builds. It +reports the exact JEDEC ID, status-register byte, last store stage, and the +first latched storage error. Run it after an immediate `storage error` before +starting another pull; later capacity probes preserve that failure detail. + ## Capacity and package types The store reads the JEDEC capacity at runtime and accepts supported 1 MiB diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index e623f5a6..791a71c3 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -749,6 +749,7 @@ ota rescue install internal-flash nRF52 only: recover from failed 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) +ota qspi | storage QSPI nRF52 only: JEDEC/SR1/stage/latched storage error (read-only) ota folder | fold [on|off] attach/detach an external .mota folder (host daemon) ; bare = list ota config | cfg | set [autofetch|autoinstall|checkpoint] ... show/set persisted policy ota key | keys [add|rm ] trusted signer allowlist ; bare = list diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index d959d09d..1a18d4b2 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -151,6 +151,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board strcpy(reply, "OTA: status | stats | ls | get flash [rescue] | install | rescue install | " "cancel | announce | self | folder | config | key"); +#elif defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) + strcpy(reply, + "OTA: status | stats | ls | get flash | install | cancel | announce | self | qspi | " + "folder | config | key"); #else snprintf(reply, 160, "OTA: status | stats | ls | get flash | install | cancel | announce | self | folder | " @@ -522,6 +526,21 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board sprintf(reply, "OK beacon sent (serving=%s)", c.serving ? "self fw" : "nothing"); #endif + // ---- raw-QSPI staging diagnostics (read-only probe; preserves a latched fetch failure) ---- + } else if (is_cmd(a, "qspi|storage", &rest)) { +#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE) + // This probe only reads JEDEC/SR1. capacity() deliberately preserves a + // latched fetch failure so asking for diagnostics cannot erase its cause. + uint32_t qspi_capacity = c.fetch_store.capacity(); + const char *error = c.fetch_store.last_error(); + snprintf(reply, 160, "QSPI jedec=%06lX size=%luK sr1=%02X stage=%s%s%s", + (unsigned long)c.fetch_store.jedec_id(), + (unsigned long)(qspi_capacity / 1024), c.fetch_store.status1(), + c.fetch_store.last_stage(), error[0] ? " error=" : "", error); +#else + strcpy(reply, "ERR this build does not use a QSPI OTA store"); +#endif + // ---- running firmware identity (compare against a delta's base_hash) ---- } else if (is_cmd(a, "self|id", &rest)) { SelfFwInfo fi; diff --git a/src/helpers/ota/OtaStoreQspiNrf52.cpp b/src/helpers/ota/OtaStoreQspiNrf52.cpp index 83278dcc..93292d96 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.cpp +++ b/src/helpers/ota/OtaStoreQspiNrf52.cpp @@ -4,6 +4,7 @@ #include "OtaByteIO.h" #include "OtaFlashLayout_nrf52.h" +#include "hal/nrf_gpio.h" #include "hal/nrf_qspi.h" #include "nrf.h" @@ -139,6 +140,28 @@ static uint8_t qspi_io3_pin() { #endif } +static void configure_qspi_gpio(const nrf_qspi_pins_t& pins) { + // The QSPI PSEL registers do not configure GPIO drive strength. Nordic + // initializes every connected pad as input-disconnected/high-drive before + // the peripheral takes ownership and controls each pin's direction. + nrf_gpio_pin_clear(pins.sck_pin); // mode 0 idle level + nrf_gpio_pin_set(pins.csn_pin); // never select the NOR during handoff + nrf_gpio_cfg(pins.sck_pin, NRF_GPIO_PIN_DIR_INPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + nrf_gpio_cfg(pins.csn_pin, NRF_GPIO_PIN_DIR_INPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + nrf_gpio_cfg(pins.io0_pin, NRF_GPIO_PIN_DIR_INPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + nrf_gpio_cfg(pins.io1_pin, NRF_GPIO_PIN_DIR_INPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + if (pins.io2_pin != NRF_QSPI_PIN_NOT_CONNECTED) + nrf_gpio_cfg(pins.io2_pin, NRF_GPIO_PIN_DIR_INPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + if (pins.io3_pin != NRF_QSPI_PIN_NOT_CONNECTED) + nrf_gpio_cfg(pins.io3_pin, NRF_GPIO_PIN_DIR_INPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); +} + } // namespace OtaStoreQspiNrf52::OtaStoreQspiNrf52() { @@ -151,6 +174,9 @@ OtaStoreQspiNrf52::~OtaStoreQspiNrf52() { void OtaStoreQspiNrf52::fail(const char *message) { _io_ok = false; + // Keep the first concrete failure from a manifest attempt. Higher-level + // wrappers must not replace a program/status error with a generic one. + if (_error[0]) return; strncpy(_error, message ? message : "QSPI error", sizeof(_error) - 1); _error[sizeof(_error) - 1] = 0; } @@ -165,6 +191,7 @@ void OtaStoreQspiNrf52::resetSession() { memset(_data_page, 0xFF, sizeof(_data_page)); memset(_known_pages, 0, sizeof(_known_pages)); _error[0] = 0; + _stage = OtaQspiStage::IDLE; } bool OtaStoreQspiNrf52::pageKnown(uint32_t page) const { @@ -199,10 +226,35 @@ bool OtaStoreQspiNrf52::customInstruction(uint8_t opcode, uint8_t length, uint8_ return true; } +bool OtaStoreQspiNrf52::readStatus1() { + uint8_t status = 0; + if (!customInstruction(0x05, NRF_QSPI_CINSTR_LEN_2B, &status)) { + fail("QSPI status read failed"); + return false; + } + _status1 = status; + return true; +} + +bool OtaStoreQspiNrf52::waitMemoryReady(uint32_t timeout_ms) { + const uint32_t started = millis(); + for (;;) { + if (!readStatus1()) return false; + if (!mota_qspi_status_busy(_status1)) { + _memory_operation_pending = false; + return true; + } + // This also covers recovery after a reset during an earlier NOR write: + // once WIP is observed, releaseFlash must not send DPD or cut power unless + // a later status read proves the operation finished. + _memory_operation_pending = true; + if ((uint32_t)(millis() - started) >= timeout_ms) return false; + delay(1); + } +} + bool OtaStoreQspiNrf52::ensureFlash() { if (_qspi_ready) return true; - _error[0] = 0; - _io_ok = true; #if defined(OTA_QSPI_POWER_PIN) pinMode(OTA_QSPI_POWER_PIN, OUTPUT); @@ -229,6 +281,7 @@ bool OtaStoreQspiNrf52::ensureFlash() { pins.io1_pin = qspi_io1_pin(); pins.io2_pin = qspi_io2_pin(); pins.io3_pin = qspi_io3_pin(); + configure_qspi_gpio(pins); nrf_qspi_pins_set(NRF_QSPI, &pins); nrf_qspi_prot_conf_t protocol; @@ -251,6 +304,7 @@ bool OtaStoreQspiNrf52::ensureFlash() { nrf_qspi_enable(NRF_QSPI); _qspi_active = true; + _stage = OtaQspiStage::ACTIVATE; nrf_qspi_event_clear(NRF_QSPI, NRF_QSPI_EVENT_READY); nrf_qspi_task_trigger(NRF_QSPI, NRF_QSPI_TASK_ACTIVATE); if (!waitReady(1000)) { @@ -260,23 +314,45 @@ bool OtaStoreQspiNrf52::ensureFlash() { } // Release from deep power-down, then identify capacity from the JEDEC byte. + _stage = OtaQspiStage::WAKE; if (!customInstruction(0xAB, NRF_QSPI_CINSTR_LEN_1B)) { releaseFlash(); fail("QSPI wake failed"); return false; } _qspi_awake = true; + // Until SR1 is read successfully, conservatively assume an operation from + // the preceding boot may still be active. Only waitMemoryReady() may clear + // this guard after observing WIP=0. + _memory_operation_pending = true; delayMicroseconds(MOTA_QSPI_DPD_WAKE_GUARD_US); + + // Recover cleanly if this boot/activation follows an interrupted program or + // erase. RDID is not guaranteed to respond while WIP is set, so prove the + // NOR idle before requesting its identity. + _stage = OtaQspiStage::STATUS; + if (!waitMemoryReady(30000)) { + if (_io_ok) fail("QSPI wake busy timed out"); + releaseFlash(); + return false; + } + alignas(4) uint8_t jedec[4] = { 0, 0, 0, 0 }; - if (!customInstruction(0x9F, NRF_QSPI_CINSTR_LEN_4B, jedec) || jedec[0] == 0 || jedec[0] == 0xFF || - jedec[2] < 20 || jedec[2] > 24) { + _stage = OtaQspiStage::JEDEC; + _jedec_id = 0; + if (!customInstruction(0x9F, NRF_QSPI_CINSTR_LEN_4B, jedec)) { + releaseFlash(); + fail("QSPI JEDEC read failed"); + return false; + } + _jedec_id = ((uint32_t)jedec[0] << 16) | ((uint32_t)jedec[1] << 8) | jedec[2]; + if (jedec[0] == 0 || jedec[0] == 0xFF || jedec[2] < 20 || jedec[2] > 24) { releaseFlash(); fail("QSPI JEDEC ID/capacity unsupported"); return false; } #ifdef OTA_QSPI_EXPECTED_JEDEC_ID - const uint32_t jedec_id = ((uint32_t)jedec[0] << 16) | ((uint32_t)jedec[1] << 8) | jedec[2]; - if (jedec_id != (uint32_t)OTA_QSPI_EXPECTED_JEDEC_ID) { + if (_jedec_id != (uint32_t)OTA_QSPI_EXPECTED_JEDEC_ID) { releaseFlash(); fail("QSPI JEDEC ID does not match target"); return false; @@ -300,7 +376,7 @@ bool OtaStoreQspiNrf52::ensureFlash() { } void OtaStoreQspiNrf52::releaseFlash() { - if (_qspi_awake) { + if (_qspi_awake && !_memory_operation_pending) { // The repeater can remain idle for hours after a capacity/status probe or // a completed checkpoint. Put the NOR into deep power-down before // releasing the nRF QSPI peripheral instead of leaving both active for @@ -311,24 +387,45 @@ void OtaStoreQspiNrf52::releaseFlash() { // plan_layout() is intentionally followed immediately by begin(). delayMicroseconds(MOTA_QSPI_DPD_ENTRY_GUARD_US); } + // If WIP could not be proven clear, do not send DPD or cut flash power. + // Deactivating the controller leaves CS high while the NOR finishes or + // remains available for a later diagnostic probe. + // Match nrfx_qspi_uninit(): DEACTIVATE does not require a READY wait before // disabling the peripheral. Trigger it after every successful ENABLE, even // when wake or JEDEC identification failed before _qspi_ready was set. if (_qspi_active) { + if (_memory_operation_pending) { + // QSPI owns pin direction while enabled. Preload and configure CS# high + // before releasing the peripheral so an unresolved NOR operation cannot + // see a floating or asserted chip select during the handoff. + const uint8_t cs_pin = qspi_cs_pin(); + nrf_gpio_pin_set(cs_pin); + nrf_gpio_cfg(cs_pin, NRF_GPIO_PIN_DIR_OUTPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + } nrf_qspi_event_clear(NRF_QSPI, NRF_QSPI_EVENT_READY); nrf_qspi_task_trigger(NRF_QSPI, NRF_QSPI_TASK_DEACTIVATE); nrf_qspi_disable(NRF_QSPI); nrf_qspi_event_clear(NRF_QSPI, NRF_QSPI_EVENT_READY); + if (_memory_operation_pending) { + const nrf_qspi_pins_t disconnected = { + NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED, + NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED, + NRF_QSPI_PIN_NOT_CONNECTED, NRF_QSPI_PIN_NOT_CONNECTED + }; + nrf_qspi_pins_set(NRF_QSPI, &disconnected); + } } _qspi_active = false; _qspi_awake = false; _qspi_ready = false; #if defined(OTA_QSPI_POWER_PIN) - digitalWrite(OTA_QSPI_POWER_PIN, LOW); + if (!_memory_operation_pending) digitalWrite(OTA_QSPI_POWER_PIN, LOW); #elif defined(PIN_FLASH_EN) - digitalWrite(PIN_FLASH_EN, LOW); + if (!_memory_operation_pending) digitalWrite(PIN_FLASH_EN, LOW); #elif defined(QSPI_FLASH_EN) - digitalWrite(QSPI_FLASH_EN, LOW); + if (!_memory_operation_pending) digitalWrite(QSPI_FLASH_EN, LOW); #endif } @@ -341,6 +438,7 @@ bool OtaStoreQspiNrf52::dmaReadAligned(uint32_t address, uint32_t length) { fail("QSPI aligned read invalid"); return false; } + _stage = OtaQspiStage::READ; nrf_qspi_read_buffer_set(NRF_QSPI, _bounce, length, address); nrf_qspi_event_clear(NRF_QSPI, NRF_QSPI_EVENT_READY); nrf_qspi_task_trigger(NRF_QSPI, NRF_QSPI_TASK_READSTART); @@ -358,13 +456,23 @@ bool OtaStoreQspiNrf52::dmaWriteAligned(uint32_t address, uint32_t length) { fail("QSPI aligned program invalid"); return false; } + _stage = OtaQspiStage::PROGRAM; nrf_qspi_write_buffer_set(NRF_QSPI, _bounce, length, address); nrf_qspi_event_clear(NRF_QSPI, NRF_QSPI_EVENT_READY); + _memory_operation_pending = true; nrf_qspi_task_trigger(NRF_QSPI, NRF_QSPI_TASK_WRITESTART); if (!waitReady(10000)) { fail("QSPI program timed out"); return false; } + // READY only reports that the page-program command/data were sent. Poll the + // NOR WIP bit before verifying, issuing another operation, or entering DPD. + _stage = OtaQspiStage::PROGRAM_BUSY; + if (!waitMemoryReady(10000)) { + if (_io_ok) fail("QSPI program busy timed out"); + return false; + } + _memory_operation_pending = false; return true; } @@ -436,13 +544,22 @@ bool OtaStoreQspiNrf52::rawWrite(uint32_t address, const void *data, uint32_t le bool OtaStoreQspiNrf52::rawErasePage(uint32_t address) { if (!ensureFlash() || (address & (PAGE - 1)) != 0 || address > _flash_size - PAGE) return false; + _stage = OtaQspiStage::ERASE; nrf_qspi_erase_ptr_set(NRF_QSPI, address, NRF_QSPI_ERASE_LEN_4KB); nrf_qspi_event_clear(NRF_QSPI, NRF_QSPI_EVENT_READY); + _memory_operation_pending = true; nrf_qspi_task_trigger(NRF_QSPI, NRF_QSPI_TASK_ERASESTART); if (!waitReady(30000)) { fail("QSPI erase timed out"); return false; } + // As with program, READY precedes completion of the flash's internal erase. + _stage = OtaQspiStage::ERASE_BUSY; + if (!waitMemoryReady(30000)) { + if (_io_ok) fail("QSPI erase busy timed out"); + return false; + } + _memory_operation_pending = false; return true; } @@ -498,12 +615,36 @@ bool OtaStoreQspiNrf52::useDataPage(uint32_t page) { uint32_t OtaStoreQspiNrf52::capacity() const { OtaStoreQspiNrf52 *self = const_cast(this); + // A read-only CLI probe must not erase the reason a fetch just failed. + // Preserve the latched failure while still refreshing the JEDEC ID/capacity. + const bool preserve_failure = self->_error[0] != 0; + char saved_error[sizeof(self->_error)]; + OtaQspiStage saved_stage = self->_stage; + uint8_t saved_status1 = self->_status1; + bool saved_io_ok = self->_io_ok; + if (preserve_failure) { + strncpy(saved_error, self->_error, sizeof(saved_error)); + saved_error[sizeof(saved_error) - 1] = 0; + } uint32_t result = self->ensureFlash() ? self->_flash_size : 0; self->releaseFlash(); + if (preserve_failure) { + strncpy(self->_error, saved_error, sizeof(self->_error)); + self->_error[sizeof(self->_error) - 1] = 0; + self->_stage = saved_stage; + self->_status1 = saved_status1; + self->_io_ok = saved_io_ok; + } return result; } bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, uint32_t payload_size) { + // This is the start of a new manifest admission attempt. Clear diagnostics + // left by the normal empty-store reopen probe, then latch the first error + // from this attempt until the operator reads it or starts another attempt. + _error[0] = 0; + _io_ok = true; + _stage = OtaQspiStage::IDLE; const uint32_t app_base = mota_nrf52_app_base(); if (image_size == 0 || payload_size == 0 || app_base >= MOTA_NRF52_APP_END || image_size > MOTA_NRF52_APP_END - app_base) { @@ -517,8 +658,12 @@ bool OtaStoreQspiNrf52::plan_layout(bool, uint32_t image_size, uint32_t, uint32_ } bool OtaStoreQspiNrf52::begin(uint32_t total_size) { - if (!ensureFlash() || total_size < 13 || total_size > _flash_size) { - if (_io_ok) fail("QSPI lacks space for update"); + if (!ensureFlash()) { + releaseFlash(); + return false; + } + if (total_size < 13 || total_size > _flash_size) { + fail("QSPI lacks space for update"); releaseFlash(); return false; } @@ -527,9 +672,19 @@ bool OtaStoreQspiNrf52::begin(uint32_t total_size) { // replaced with the new header at the first checkpoint/finalize. uint8_t zero[4] = { 0, 0, 0, 0 }; uint8_t check[sizeof(zero)]; - if (!rawWrite(0, zero, sizeof(zero)) || !rawRead(0, check, sizeof(check)) || - memcmp(check, zero, sizeof(zero)) != 0) { - fail("QSPI old-container invalidation failed"); + if (!rawWrite(0, zero, sizeof(zero))) { + releaseFlash(); + return false; + } + if (!rawRead(0, check, sizeof(check))) { + releaseFlash(); + return false; + } + if (memcmp(check, zero, sizeof(zero)) != 0) { + _stage = OtaQspiStage::INVALIDATE_VERIFY; + char message[sizeof(_error)]; + snprintf(message, sizeof(message), "QSPI invalidation mismatch sr1=%02X", _status1); + fail(message); releaseFlash(); return false; } @@ -540,11 +695,22 @@ bool OtaStoreQspiNrf52::begin(uint32_t total_size) { } bool OtaStoreQspiNrf52::set_meta_size(uint32_t meta_bytes) { - return _total >= 13 && meta_bytes <= PAGE; + if (_total < 13 || meta_bytes > PAGE) { + _stage = OtaQspiStage::META_SIZE; + fail("QSPI metadata exceeds first page"); + return false; + } + return true; } bool OtaStoreQspiNrf52::write(uint32_t offset, const uint8_t *data, uint32_t len) { - if (!_io_ok || !data || (uint64_t)offset + len > _total) { + if (!_io_ok) { + releaseFlash(); + return false; + } + if (!data || (uint64_t)offset + len > _total) { + _stage = OtaQspiStage::BUFFER_WRITE; + fail(!data ? "QSPI write buffer missing" : "QSPI write outside container"); releaseFlash(); return false; } diff --git a/src/helpers/ota/OtaStoreQspiNrf52.h b/src/helpers/ota/OtaStoreQspiNrf52.h index 2b1d8487..7d402faf 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.h +++ b/src/helpers/ota/OtaStoreQspiNrf52.h @@ -30,6 +30,49 @@ static const uint32_t MOTA_QSPI_DPD_ENTRY_GUARD_US = 50u; // Release-from-deep-power-down latency is as high as 45 us on supported NOR. static const uint32_t MOTA_QSPI_DPD_WAKE_GUARD_US = 50u; +// The nRF QSPI READY event only says that a program/erase command and its +// data reached the NOR. The memory can remain internally busy afterwards; +// issuing another command or powering it down before WIP clears is unsafe. +static const uint8_t MOTA_QSPI_STATUS_WIP = 0x01u; + +inline bool mota_qspi_status_busy(uint8_t status1) { + return (status1 & MOTA_QSPI_STATUS_WIP) != 0; +} + +enum class OtaQspiStage : uint8_t { + IDLE = 0, + ACTIVATE, + WAKE, + JEDEC, + STATUS, + READ, + PROGRAM, + PROGRAM_BUSY, + ERASE, + ERASE_BUSY, + INVALIDATE_VERIFY, + META_SIZE, + BUFFER_WRITE +}; + +inline const char *mota_qspi_stage_name(OtaQspiStage stage) { + switch (stage) { + case OtaQspiStage::ACTIVATE: return "activate"; + case OtaQspiStage::WAKE: return "wake"; + case OtaQspiStage::JEDEC: return "jedec"; + case OtaQspiStage::STATUS: return "status"; + case OtaQspiStage::READ: return "read"; + case OtaQspiStage::PROGRAM: return "program"; + case OtaQspiStage::PROGRAM_BUSY: return "program-busy"; + case OtaQspiStage::ERASE: return "erase"; + case OtaQspiStage::ERASE_BUSY: return "erase-busy"; + case OtaQspiStage::INVALIDATE_VERIFY: return "invalidate-verify"; + case OtaQspiStage::META_SIZE: return "meta-size"; + case OtaQspiStage::BUFFER_WRITE: return "buffer-write"; + default: return "idle"; + } +} + } // namespace ota } // namespace mesh @@ -53,9 +96,13 @@ class OtaStoreQspiNrf52 : public OtaStore { uint32_t _total = 0; uint32_t _flash_size = 0; + uint32_t _jedec_id = 0; + uint8_t _status1 = 0xFF; + OtaQspiStage _stage = OtaQspiStage::IDLE; bool _qspi_active = false; bool _qspi_awake = false; bool _qspi_ready = false; + bool _memory_operation_pending = false; bool _io_ok = true; bool _meta_dirty = false; bool _data_dirty = false; @@ -72,6 +119,8 @@ class OtaStoreQspiNrf52 : public OtaStore { void releaseFlash(); bool waitReady(uint32_t timeout_ms); bool customInstruction(uint8_t opcode, uint8_t length, uint8_t *rx = nullptr); + bool readStatus1(); + bool waitMemoryReady(uint32_t timeout_ms); bool dmaReadAligned(uint32_t address, uint32_t length); bool dmaWriteAligned(uint32_t address, uint32_t length); bool rawRead(uint32_t address, void *data, uint32_t length); @@ -104,6 +153,9 @@ public: // Sets APRV only after all app-side verification gates have passed. bool approve_for_bootloader(); const char *last_error() const { return _error; } + const char *last_stage() const { return mota_qspi_stage_name(_stage); } + uint32_t jedec_id() const { return _jedec_id; } + uint8_t status1() const { return _status1; } }; } // namespace ota diff --git a/test/test_ota/test_ota_flashplan.cpp b/test/test_ota/test_ota_flashplan.cpp index e944f42a..d42057b0 100644 --- a/test/test_ota/test_ota_flashplan.cpp +++ b/test/test_ota/test_ota_flashplan.cpp @@ -35,6 +35,22 @@ TEST(OtaQspiTiming, PreservesDeepPowerDownEntryAndWakeGuards) { EXPECT_GE(MOTA_QSPI_DPD_WAKE_GUARD_US, 45u); } +TEST(OtaQspiStatus, TreatsOnlyTheNorWriteInProgressBitAsBusy) { + // Nordic READY is not flash completion. The hardware path polls RDSR until + // this predicate clears after every program and erase operation. + EXPECT_FALSE(mota_qspi_status_busy(0x00)); + EXPECT_FALSE(mota_qspi_status_busy(0xFC)); + EXPECT_TRUE(mota_qspi_status_busy(0x01)); + EXPECT_TRUE(mota_qspi_status_busy(0xFF)); +} + +TEST(OtaQspiDiagnostics, ExposesStableFailureStageNames) { + EXPECT_STREQ(mota_qspi_stage_name(OtaQspiStage::PROGRAM), "program"); + EXPECT_STREQ(mota_qspi_stage_name(OtaQspiStage::PROGRAM_BUSY), "program-busy"); + EXPECT_STREQ(mota_qspi_stage_name(OtaQspiStage::ERASE_BUSY), "erase-busy"); + EXPECT_STREQ(mota_qspi_stage_name(OtaQspiStage::INVALIDATE_VERIFY), "invalidate-verify"); +} + TEST(OtaFlashPlan, SelectsCeilingFromLinkedLayoutAndStorage) { // Actual internal secondary storage is authoritative regardless of linker selection. EXPECT_EQ(mota_nrf52_stage_ceiling_for_layout(EXPANDED, true), LEGACY); diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 1eac0ac7..2190e5fa 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -728,7 +728,8 @@ class ReliabilityTests(unittest.TestCase): "OTA | no download | target:1234ABCD hw=Xiao_nrf52 | bl:QSPI blrc:B0", "> 0.9.2-OTAFIX2.4", "self body=1 image=2 base_hash=0011223344556677 | " - "bootloader: QSPI apply OK (abi=2 codecs=0x5)", + "QSPI store:2048K | bootloader: QSPI apply OK " + "(abi=2 codecs=0x5)", "OTA | fw v1.17.0 id=00112233", ])