diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 7ea0132ed..d9f4e327a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -467,6 +467,7 @@ jobs: python3 -B test/test_companion_contact_partial_tail.py -v python3 -B test/test_companion_channel_storage.py -v python3 -B test/test_nrf52_extrafs_contract.py -v + python3 -B test/test_xiao_qspi_recovery.py -v python3 -B test/test_companion_ota_config.py -v python3 -B test/test_ota_identity_policy.py -v python3 -B test/test_companion_radio_gain_restore.py -v diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 8092b877d..c43982894 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -795,6 +795,16 @@ bool DataStore::removeFile(FILESYSTEM* fs, const char* filename) { } bool DataStore::formatFileSystem() { +#if defined(NRF52_PLATFORM) && defined(QSPIFLASH) + // An explicit erase must cover every configured store. A quarantined QSPI + // chip may still hold authoritative contacts/channels, and its disabled + // peripheral cannot safely be formatted through the configured pointer. + // Refuse before changing primary storage or any in-memory recovery state. + if (_configuredFsExtra != nullptr && _fsExtra != _configuredFsExtra) { + MESH_DEBUG_PRINTLN("DataStore: erase refused while QSPI storage is unavailable"); + return false; + } +#endif #if defined(ESP32_PLATFORM) // A failed format keeps the latest accepted RAM packets available to retry. _advert_write.pause(); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ed79f8127..03dce13e3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -7474,20 +7474,21 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type } } else if (cmd_frame[0] == CMD_FACTORY_RESET && len >= 6 && memcmp(&cmd_frame[1], "reset", 5) == 0) { - if (_serial) { - MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)"); - _serial->disable(); // Phone app disconnects before we can send OK frame so it's safe here - } -#if MESH_ENABLE_TX_BEACONS - suspendBeacons(); -#endif + BaseSerialInterface* const reply_route = _serial->captureReplyRoute(); + // Formatting is synchronous in the command-dispatch loop. Keep the + // requesting transport available if storage rejects/fails the erase. bool success = _store->formatFileSystem(); if (success) { +#if MESH_ENABLE_TX_BEACONS + suspendBeacons(); +#endif + MESH_DEBUG_PRINTLN("Factory reset: disabling interfaces before reboot"); + _serial->disable(); writeOKFrame(); delay(1000); board.reboot(); // doesn't return } else { - writeErrFrame(ERR_CODE_FILE_IO_ERROR); + writeErrFrame(ERR_CODE_FILE_IO_ERROR, reply_route); } } else if (cmd_frame[0] == CMD_SET_FLOOD_SCOPE_KEY && len >= 2 && cmd_frame[1] == 0) { if (len >= 2 + 16) { diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 5f9175336..14c7d18ab 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -230,6 +230,7 @@ static bool isNetworkTerminalActive(); #endif #if defined(QSPIFLASH) #include + #include DataStore store(InternalFS, QSPIFlash, rtc_clock); #else #if defined(EXTRAFS) @@ -3689,6 +3690,16 @@ void setup() { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #if defined(QSPIFLASH) + #if defined(XIAO_NRF52) + // The external NOR can remain asleep across an MCU/DFU reset. Wake it + // before CustomLFS activates QSPI, without changing any stored bytes. + const nrf_qspi_pins_t qspi_pins = { + (uint8_t)g_ADigitalPinMap[PIN_QSPI_SCK], (uint8_t)g_ADigitalPinMap[PIN_QSPI_CS], + (uint8_t)g_ADigitalPinMap[PIN_QSPI_IO0], (uint8_t)g_ADigitalPinMap[PIN_QSPI_IO1], + (uint8_t)g_ADigitalPinMap[PIN_QSPI_IO2], (uint8_t)g_ADigitalPinMap[PIN_QSPI_IO3] + }; + mesh::nrf52::wakeQspiFlashBeforeActivate(qspi_pins); + #endif if (!QSPIFlash.begin()) { // debug output might not be available at this point, might be too early. maybe should fall back to InternalFS here? MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: failed to initialize"); diff --git a/src/helpers/nrf52/QspiFlashWake.h b/src/helpers/nrf52/QspiFlashWake.h new file mode 100644 index 000000000..0801a0e3c --- /dev/null +++ b/src/helpers/nrf52/QspiFlashWake.h @@ -0,0 +1,57 @@ +#pragma once + +#include "QspiFlashWakePolicy.h" + +#if defined(NRF52_PLATFORM) +#include +#include +#include +#endif + +namespace mesh { +namespace nrf52 { + +#if defined(NRF52_PLATFORM) +inline void wakeQspiFlashBeforeActivate(const nrf_qspi_pins_t& pins) { + // TASKS_ACTIVATE talks to the NOR before CINSTR is available. A sleeping + // chip ignores that traffic and can leave activation waiting forever. + // Send only the non-destructive 0xAB wake byte over mode-0 GPIO first. + nrf_gpio_pin_clear(pins.sck_pin); + nrf_gpio_pin_set(pins.csn_pin); + nrf_gpio_pin_set(pins.io0_pin); + if (pins.io2_pin != NRF_QSPI_PIN_NOT_CONNECTED) nrf_gpio_pin_set(pins.io2_pin); + if (pins.io3_pin != NRF_QSPI_PIN_NOT_CONNECTED) nrf_gpio_pin_set(pins.io3_pin); + + nrf_gpio_cfg(pins.sck_pin, NRF_GPIO_PIN_DIR_OUTPUT, 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_OUTPUT, 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_OUTPUT, 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_OUTPUT, 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_OUTPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + + delayMicroseconds(1); + nrf_gpio_pin_clear(pins.csn_pin); + delayMicroseconds(1); + for (uint8_t bit = 0; bit < 8u; bit++) { + nrf_gpio_pin_write(pins.io0_pin, qspiReleaseFromDeepPowerDownBit(bit)); + delayMicroseconds(1); + nrf_gpio_pin_set(pins.sck_pin); + delayMicroseconds(1); + nrf_gpio_pin_clear(pins.sck_pin); + delayMicroseconds(1); + } + nrf_gpio_pin_set(pins.csn_pin); + delayMicroseconds(QSPI_DPD_WAKE_GUARD_US); +} +#endif + +} // namespace nrf52 +} // namespace mesh diff --git a/src/helpers/nrf52/QspiFlashWakePolicy.h b/src/helpers/nrf52/QspiFlashWakePolicy.h new file mode 100644 index 000000000..b9db8cd78 --- /dev/null +++ b/src/helpers/nrf52/QspiFlashWakePolicy.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace mesh { +namespace nrf52 { + +// Supported NOR parts need up to 45 us after release from deep power-down. +static const uint32_t QSPI_DPD_WAKE_GUARD_US = 50u; +static const uint8_t QSPI_RELEASE_FROM_DPD_OPCODE = 0xABu; + +inline bool qspiReleaseFromDeepPowerDownBit(uint8_t bit_index) { + return bit_index < 8u && + ((QSPI_RELEASE_FROM_DPD_OPCODE >> (7u - bit_index)) & 1u) != 0; +} + +} // namespace nrf52 +} // namespace mesh diff --git a/src/helpers/ota/OtaStoreQspiNrf52.cpp b/src/helpers/ota/OtaStoreQspiNrf52.cpp index 16999920e..206dfcced 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 #include "hal/nrf_gpio.h" #include "hal/nrf_qspi.h" #include "nrf.h" @@ -235,47 +236,6 @@ static void release_rak3401_radio_bus() { } #endif -static void wake_qspi_flash_before_activate(const nrf_qspi_pins_t& pins) { - // nRF52840 TASKS_ACTIVATE communicates with the NOR before CINSTR is - // available. A flash put to sleep by our preceding 0xB9 ignores that - // activation traffic, so issue the only command it accepts (0xAB) with a - // short mode-0 GPIO transaction first. IO2/WP# and IO3/HOLD# stay high. - nrf_gpio_pin_clear(pins.sck_pin); - nrf_gpio_pin_set(pins.csn_pin); - nrf_gpio_pin_set(pins.io0_pin); - if (pins.io2_pin != NRF_QSPI_PIN_NOT_CONNECTED) nrf_gpio_pin_set(pins.io2_pin); - if (pins.io3_pin != NRF_QSPI_PIN_NOT_CONNECTED) nrf_gpio_pin_set(pins.io3_pin); - - nrf_gpio_cfg(pins.sck_pin, NRF_GPIO_PIN_DIR_OUTPUT, 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_OUTPUT, 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_OUTPUT, 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_OUTPUT, 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_OUTPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, - NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); - - delayMicroseconds(1); - nrf_gpio_pin_clear(pins.csn_pin); - delayMicroseconds(1); - for (uint8_t bit = 0; bit < 8u; bit++) { - nrf_gpio_pin_write(pins.io0_pin, mota_qspi_release_from_dpd_bit(bit)); - delayMicroseconds(1); - nrf_gpio_pin_set(pins.sck_pin); - delayMicroseconds(1); - nrf_gpio_pin_clear(pins.sck_pin); - delayMicroseconds(1); - } - nrf_gpio_pin_set(pins.csn_pin); - delayMicroseconds(MOTA_QSPI_DPD_WAKE_GUARD_US); -} - #if defined(OTA_RAK_AUTO_STORE) static uint8_t gpio_spi_byte(const nrf_qspi_pins_t& pins, uint8_t out) { uint8_t in = 0; @@ -299,7 +259,7 @@ static uint32_t probe_nor(uint8_t cs) { }; // A previous reset may have left the NOR in deep power-down. This GPIO // wake precedes the nRF QSPI ACTIVATE for the same reason as ensureFlash(). - wake_qspi_flash_before_activate(pins); + mesh::nrf52::wakeQspiFlashBeforeActivate(pins); // The wake helper leaves GPIO input buffers disconnected for QSPI handoff. // This bit-banged JEDEC read instead samples IO1 through GPIO->IN, which // requires the MISO input buffer to be connected. @@ -552,7 +512,7 @@ bool OtaStoreQspiNrf52::ensureFlash() { // successful RDSR prove WIP clear, every failure path must retain power and // hold CS# high instead of sending B9 or cutting the flash rail. _memory_operation_pending = true; - wake_qspi_flash_before_activate(pins); + mesh::nrf52::wakeQspiFlashBeforeActivate(pins); configure_qspi_gpio(pins); nrf_qspi_pins_set(NRF_QSPI, &pins); diff --git a/src/helpers/ota/OtaStoreQspiNrf52.h b/src/helpers/ota/OtaStoreQspiNrf52.h index c21e70eec..720b47db1 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.h +++ b/src/helpers/ota/OtaStoreQspiNrf52.h @@ -42,6 +42,8 @@ #endif #endif +#include "../nrf52/QspiFlashWakePolicy.h" + namespace mesh { namespace ota { @@ -52,8 +54,10 @@ namespace ota { 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; -static const uint8_t MOTA_QSPI_RELEASE_FROM_DPD_OPCODE = 0xABu; +static const uint32_t MOTA_QSPI_DPD_WAKE_GUARD_US = + mesh::nrf52::QSPI_DPD_WAKE_GUARD_US; +static const uint8_t MOTA_QSPI_RELEASE_FROM_DPD_OPCODE = + mesh::nrf52::QSPI_RELEASE_FROM_DPD_OPCODE; // TASKS_ACTIVATE initiates traffic with the external memory. A NOR in deep // power-down ignores that traffic and the nRF QSPI peripheral can wait forever @@ -61,8 +65,7 @@ static const uint8_t MOTA_QSPI_RELEASE_FROM_DPD_OPCODE = 0xABu; // owns the pins. Keep the bit order host-testable instead of open-coding it in // the hardware-only path. inline bool mota_qspi_release_from_dpd_bit(uint8_t bit_index) { - return bit_index < 8u && - ((MOTA_QSPI_RELEASE_FROM_DPD_OPCODE >> (7u - bit_index)) & 1u) != 0; + return mesh::nrf52::qspiReleaseFromDeepPowerDownBit(bit_index); } // The nRF QSPI READY event only says that a program/erase command and its diff --git a/test/test_xiao_qspi_recovery.py b/test/test_xiao_qspi_recovery.py new file mode 100644 index 000000000..356b37c26 --- /dev/null +++ b/test/test_xiao_qspi_recovery.py @@ -0,0 +1,334 @@ +"""Exercise sleeping NOR startup and failed factory-reset recovery natively.""" + +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +from test_nrf52_extrafs_contract import function_body + + +ROOT = Path(__file__).resolve().parents[1] + + +def method(source, signature): + start = source.index(signature) + return source[start:source.index("{", start) + 1] + function_body(source, signature) + "}" + + +def run_native(source, headers=None): + with tempfile.TemporaryDirectory(prefix="mesh-xiao-qspi-") as temp: + work = Path(temp) + for name, content in (headers or {}).items(): + path = work / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="ascii") + cpp = work / "check.cpp" + cpp.write_text(source, encoding="ascii") + binary = work / "check" + compiled = subprocess.run([ + "c++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-omit-frame-pointer", + "-fno-pie", "-no-pie", "-I", str(work), "-I", str(ROOT / "src"), + str(cpp), "-o", str(binary), + ], capture_output=True, text=True) + if compiled.returncode: + raise AssertionError(compiled.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + +GPIO_DECLARATIONS = r"""#pragma once +#include +static constexpr uint8_t NRF_QSPI_PIN_NOT_CONNECTED = 0xFFu; +struct nrf_qspi_pins_t { + uint8_t sck_pin, csn_pin, io0_pin, io1_pin, io2_pin, io3_pin; +}; +enum { NRF_GPIO_PIN_DIR_INPUT, NRF_GPIO_PIN_DIR_OUTPUT, + NRF_GPIO_PIN_INPUT_DISCONNECT, NRF_GPIO_PIN_NOPULL, + NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE }; +void nrf_gpio_pin_clear(uint32_t); +void nrf_gpio_pin_set(uint32_t); +void nrf_gpio_pin_write(uint32_t, bool); +void nrf_gpio_cfg(uint32_t, int, int, int, int, int); +void delayMicroseconds(uint32_t); +""" + +GPIO_RUNTIME = r""" +#define NRF52_PLATFORM 1 +#include +#include +#include +#include + +// Register-level adapter records pin latches/directions and feeds a sleeping +// serial NOR model only on clock edges. ACTIVATE cannot complete while asleep. +static nrf_qspi_pins_t expected; +static std::array latch; +static std::array direction; +static std::vector touched; +static std::vector commands; +static unsigned now_us, wake_at, bits, byte; +static bool selected, asleep; +static const std::array contents = {0x17, 0x81, 0x05, 0x99, 1, 2, 3, 4}; +static std::array flash; + +static void reset(const nrf_qspi_pins_t& pins, bool sleeping) { + expected = pins; latch.fill(false); direction.fill(-1); + touched.clear(); commands.clear(); now_us = wake_at = bits = byte = 0; + selected = false; asleep = sleeping; flash = contents; +} + +static void check_pin(uint32_t pin) { + assert(pin < latch.size()); + assert(pin == expected.sck_pin || pin == expected.csn_pin || + pin == expected.io0_pin || pin == expected.io1_pin || + pin == expected.io2_pin || pin == expected.io3_pin); + touched.push_back(pin); +} + +void nrf_gpio_pin_write(uint32_t pin, bool level) { + check_pin(pin); + const bool old = latch[pin]; + latch[pin] = level; + if (pin == expected.csn_pin && old && !level) { + assert(!latch[expected.sck_pin]); + assert(direction[expected.io1_pin] == NRF_GPIO_PIN_DIR_INPUT); + selected = true; bits = byte = 0; + } + if (pin == expected.sck_pin && !old && level && selected) { + if (expected.io2_pin != NRF_QSPI_PIN_NOT_CONNECTED) { + assert(latch[expected.io2_pin]); + } + if (expected.io3_pin != NRF_QSPI_PIN_NOT_CONNECTED) { + assert(latch[expected.io3_pin]); + } + byte = (byte << 1) | latch[expected.io0_pin]; + ++bits; + } + if (pin == expected.csn_pin && !old && level && selected) { + assert(bits == 8 && byte == 0xAB); + commands.push_back(byte); + selected = false; asleep = false; wake_at = now_us; + } +} +void nrf_gpio_pin_clear(uint32_t pin) { nrf_gpio_pin_write(pin, false); } +void nrf_gpio_pin_set(uint32_t pin) { nrf_gpio_pin_write(pin, true); } +void nrf_gpio_cfg(uint32_t pin, int mode, int, int, int, int) { + check_pin(pin); direction[pin] = mode; +} +void delayMicroseconds(uint32_t us) { now_us += us; } + +static bool activate() { + return !asleep && !selected && latch[expected.csn_pin] && + now_us - wake_at >= mesh::nrf52::QSPI_DPD_WAKE_GUARD_US; +} +static void check_waveform(const nrf_qspi_pins_t& pins, bool sleeping) { + reset(pins, sleeping); + assert(!activate()); // The old sleeping-chip path never reaches READY. + mesh::nrf52::wakeQspiFlashBeforeActivate(pins); + assert(activate()); + assert(commands == std::vector{0xAB}); + assert(now_us == 76 && bits == 8 && !selected); + assert(!latch[pins.sck_pin] && latch[pins.csn_pin]); + assert(direction[pins.io1_pin] == NRF_GPIO_PIN_DIR_INPUT); + assert(flash == contents); +} + +@VARIANT@ +static void companion_startup() { + @STARTUP@ + assert(activate()); // Stands for the first CustomLFS QSPI activation. +} + +int main() { + const nrf_qspi_pins_t xiao = {21, 25, 20, 24, 22, 23}; + check_waveform(xiao, true); + check_waveform(xiao, false); // Already awake NOR also accepts harmless AB. + check_waveform({3, 31, 30, 29, NRF_QSPI_PIN_NOT_CONNECTED, + NRF_QSPI_PIN_NOT_CONNECTED}, true); + reset(xiao, true); + companion_startup(); + assert(commands == std::vector{0xAB} && flash == contents); + for (unsigned i = 0; i < 8; ++i) { + assert(mesh::nrf52::qspiReleaseFromDeepPowerDownBit(i) == ((0xAB >> (7-i)) & 1)); + } + assert(!mesh::nrf52::qspiReleaseFromDeepPowerDownBit(8)); +} +""" + + +class XiaoQspiRecoveryTest(unittest.TestCase): + def test_real_gpio_waveform_wakes_nor_before_library_activation(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + begin = main.index("if (!QSPIFlash.begin())") + block_start = main.rindex("#if defined(XIAO_NRF52)", 0, begin) + startup = main[block_start:begin] + self.assertIn("wakeQspiFlashBeforeActivate(qspi_pins)", startup) + startup = startup[startup.index("const nrf_qspi_pins_t"):startup.index("#endif")] + + variant = (ROOT / "variants/xiao_nrf52/variant.cpp").read_text() + pin_map = variant[variant.index("g_ADigitalPinMap"):] + pin_map = pin_map[pin_map.index("{") + 1:pin_map.index("}")] + pin_map = re.sub(r"//[^\n]*", "", pin_map) + values = [int(value.strip()) for value in pin_map.split(",") if value.strip()] + header = (ROOT / "variants/xiao_nrf52/variant.h").read_text() + declarations = "static const uint32_t g_ADigitalPinMap[] = {" + ",".join(map(str, values)) + "};\n" + for pin in ("SCK", "CS", "IO0", "IO1", "IO2", "IO3"): + value = re.search(r"#define PIN_QSPI_" + pin + r"\s+\((\d+)\)", header).group(1) + declarations += "#define PIN_QSPI_" + pin + " " + value + "\n" + run_native(GPIO_RUNTIME.replace("@STARTUP@", startup).replace("@VARIANT@", declarations), { + "Arduino.h": "#pragma once\n#include \nvoid delayMicroseconds(uint32_t);\n", + "hal/nrf_gpio.h": GPIO_DECLARATIONS, + "hal/nrf_qspi.h": '#pragma once\n#include "nrf_gpio.h"\n', + }) + + def test_raw_ota_reuses_same_wake_before_activation(self): + source = (ROOT / "src/helpers/ota/OtaStoreQspiNrf52.cpp").read_text() + self.assertNotIn("static void wake_qspi_flash_before_activate", source) + self.assertEqual(source.count("mesh::nrf52::wakeQspiFlashBeforeActivate(pins);"), 2) + ensure = function_body(source, "bool OtaStoreQspiNrf52::ensureFlash(") + self.assertLess(ensure.index("wakeQspiFlashBeforeActivate(pins)"), + ensure.index("NRF_QSPI_TASK_ACTIVATE")) + + def test_quarantined_qspi_vetoes_erase_before_primary_or_ram_mutation(self): + store = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() + implementation = method(store, "bool DataStore::formatFileSystem()") + run_native(r""" +#include +#define NRF52_PLATFORM 1 +#define QSPIFLASH 1 +#define MESH_CONTACT_CACHE 0 +#define MESH_DEBUG_PRINTLN(...) do {} while(0) +struct Fs { + unsigned formats = 0; + bool ok = true; + unsigned retained = 0x1234; + bool format() { ++formats; if (!ok) return false; retained = 0; return true; } +}; +struct DataStore { + Fs* _fs; + Fs* _fsExtra; + Fs* _configuredFsExtra; + unsigned reset_calls = 0; + unsigned _contact_recovery = 9; + bool _contact_load_incomplete = true, _channel_load_incomplete = true; + bool _identity_creation_blocked = true, _prefs_load_incomplete = true; + void resetContactPageState() { ++reset_calls; } + bool formatFileSystem(); +}; +@IMPLEMENTATION@ +int main() { + Fs primary, extra; + DataStore store{&primary, nullptr, &extra}; + assert(!store.formatFileSystem()); + assert(primary.formats == 0 && extra.formats == 0 && store.reset_calls == 0); + assert(primary.retained == 0x1234 && extra.retained == 0x1234); + assert(store._contact_recovery == 9 && store._contact_load_incomplete); + assert(store._channel_load_incomplete && store._identity_creation_blocked); + assert(store._prefs_load_incomplete); + + store._fsExtra = &extra; + extra.ok = false; + assert(!store.formatFileSystem()); + assert(primary.formats == 1 && extra.formats == 1 && store.reset_calls == 1); + assert(store._contact_recovery == 9 && store._contact_load_incomplete); + extra.ok = true; + assert(store.formatFileSystem()); + assert(primary.formats == 2 && extra.formats == 2); + assert(store._contact_recovery == 0 && !store._contact_load_incomplete); + assert(!store._channel_load_incomplete && !store._identity_creation_blocked); + assert(!store._prefs_load_incomplete && extra.retained == 0); + + Fs lone_primary; + DataStore lone{&lone_primary, nullptr, nullptr}; + assert(lone.formatFileSystem() && lone_primary.formats == 1); +} +""".replace("@IMPLEMENTATION@", implementation)) + + def test_failed_factory_reset_keeps_services_and_routes_error_to_requester(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + branch = function_body(source, "cmd_frame[0] == CMD_FACTORY_RESET") + run_native(r""" +#include +#include +#include +#include +#include +#define MESH_ENABLE_TX_BEACONS 1 +#define MESH_DEBUG_PRINTLN(...) do {} while(0) +static std::vector events; +static bool beacons_on; +enum { RESP_CODE_OK = 0, RESP_CODE_ERR = 1, ERR_CODE_FILE_IO_ERROR = 5 }; +struct BaseSerialInterface { + std::array enabled = {true, true, false, false}; + BaseSerialInterface* route = this; + BaseSerialInterface* received_route = nullptr; + std::vector reply; + BaseSerialInterface* captureReplyRoute() { events.push_back(1); return route; } + void disable() { events.push_back(3); enabled.fill(false); route = nullptr; } + void writeFrameToRoute(BaseSerialInterface* target, const uint8_t* p, size_t n) { + received_route = target; reply.assign(p, p+n); events.push_back(4); + } + void writeFrame(const uint8_t* p, size_t n) { writeFrameToRoute(route, p, n); } +}; +struct Board { bool rebooted = false; void reboot() { events.push_back(6); rebooted = true; } } board; +static void delay(unsigned ms) { assert(ms == 1000); events.push_back(5); } +struct Store { + BaseSerialInterface* serial; + BaseSerialInterface* other; + std::array before; + bool ok; + bool beacons_before; + bool formatFileSystem() { + events.push_back(2); + assert(serial->enabled == before); // No disabled transport before erase. + assert(beacons_on == beacons_before); + serial->route = other; // Background route change must not steal failure. + return ok; + } +}; +struct MyMesh { + BaseSerialInterface* _serial; + Store* _store; + void reset() { @BRANCH@ } + void suspendBeacons() { events.push_back(7); beacons_on = false; } + void writeOKFrame(BaseSerialInterface* route = nullptr); + void writeErrFrame(uint8_t, BaseSerialInterface* route = nullptr); +}; +@REPLIES@ +int main() { + for (unsigned mask = 1; mask < 16; ++mask) { + for (unsigned beacon = 0; beacon < 2; ++beacon) { + BaseSerialInterface serial, requester, other; + for (unsigned i = 0; i < 4; ++i) serial.enabled[i] = (mask & (1u << i)) != 0; + serial.route = &requester; + beacons_on = beacon != 0; + Store store{&serial, &other, serial.enabled, false, beacons_on}; + MyMesh mesh{&serial, &store}; + events.clear(); board.rebooted = false; + mesh.reset(); + assert((events == std::vector{1, 2, 4})); + assert(serial.enabled == store.before && !board.rebooted); + assert(beacons_on == store.beacons_before); + assert(serial.received_route == &requester); + assert((serial.reply == std::vector{1, 5})); + + store.ok = true; serial.route = &requester; events.clear(); + mesh.reset(); + assert((events == std::vector{1, 2, 7, 3, 4, 5, 6})); + assert(board.rebooted); + assert(!beacons_on); + for (bool on : serial.enabled) assert(!on); + } + } +} +""".replace("@BRANCH@", branch).replace("@REPLIES@", "\n".join( + method(source, signature) for signature in ( + "void MyMesh::writeOKFrame(", "void MyMesh::writeErrFrame(")))) + + +if __name__ == "__main__": + unittest.main()