Allow nRF52 CPU idle while USB watchdog timers run

This commit is contained in:
mikecarper committed 2026-10-21 00:56:23 -07:00
1 parent 631c670ba7
commit 585393e36c
5 files changed
+254 -5

No files matched your search

@@ -60,6 +60,38 @@ verification and restored the original gateway service. Exact serial-port
ownership and unchanged original service configuration were verified. The
first failed guard remains a failed workflow record, not a gateway pass.
The later application-only LoRa delta to commit `2bd4cb4d1` completed all
55/55 blocks in 103 seconds. An on-air install acknowledgement verified the
unsigned internal-flash delta, and on-air postboot reads proved exact body
`1A3C9EB603C581C3` after 80 seconds plus the full `dev-2bd4cb4d` version.
The receiving RAK used LoRa for setup, progress, installation and postboot
proof. No receiver USB command drove this update. Normal radio restoration
passed. Lost postboot status/version replies required bounded getter retries;
their failed attempts remain in the private log. A subsequent independent
USB diagnostic snapshot verified the same latest image, original saved
preferences and zero core errors before the CPU sleep measurement.
That first latest-image CPU measurement FAILED. Over 90.013 seconds, the
nonhalting MEM-AP capture recorded 8013 CPU states, with one sleeping state
(about 0.01% weighted sampled time). There was no halt, lockup or reset in
the capture. Firmware diagnostics reported zero sleep-wait calls and zero
eligible time; the surrounding diagnostic window was blocked primarily by
`usb.recovery`. The saved settings were CPU power saving on, USB logging on,
USB debug off, the system watchdog on and the USB logging watchdog in Auto.
The gateway service and UART were closed during the capture.
The watchdog's Auto policy still retained a previously confirmed reader and
an initial recovery stage after the serial client closed. The shared awake
hint treated that waiting policy as work requiring the CPU to remain awake.
A focused nRF52 fix and a new physical measurement are in progress. Logging,
watchdog settings and the failed result have not been disabled or cleared to
obtain a sleep pass. CPU debug enable was already set and stayed unchanged;
the explicit firmware sleep veto is recorded independently of that limit.
Subsequent authenticated LoRa reads returned the exact same latest body and
boot uptime, with zero core errors and both receive/transmit counters
advancing by three. Radio communication remained functional after the failed
idle measurement; this is not a CPU-sleep pass.
## RAK4631 and XIAO nRF52 full QSPI LoRa updates
The RAK4631 downloaded all 325/325 blocks in 375 seconds. Its install response
@@ -137,8 +169,15 @@ Commit [720b9728c](https://github.com/mikecarper/MeshCore/commit/720b9728c)
adds cold-idle RAK host-preflight preparation. The preparation may initialize
optional staging media, but does not itself download firmware, install an
image or change saved preferences. The host updater suite passed 379 tests
with zero skips. Its targeted live cold-idle check remains pending; these
host results alone do not qualify that hardware case.
with zero skips. The targeted live cold-idle RAK4631 check subsequently
passed: one USB restart established the unchanged, idle application with
unprobed storage, then authenticated LoRa reads observed that state and the
host performed exactly one explicit storage-preparation query. The result
was external QSPI with ABI 3 and codec subset `0x5`, with unchanged body
`B0886153CE824448` and no firmware download or bootloader write. The first
host attempt lost a version reply before preparing storage; a fresh read-only
snapshot and bounded getter retries completed the check without another
restart. Both attempt records are retained.
Commit [093fd98a3](https://github.com/mikecarper/MeshCore/commit/093fd98a3)
retries lost remote clock reads within the existing bounded operation budget.
@@ -154,6 +193,25 @@ W25Q16 QSPI device and completed its full QSPI LoRa update as recorded above.
RAK3401 live diagnostics report no external NOR and 252 KiB
internal staging capacity, matching the completed internal delta case.
Commit [2bd4cb4d1](https://github.com/mikecarper/MeshCore/commit/2bd4cb4d1)
automatically restarts the existing four-beacon discovery burst when a
complete, changed update catalog becomes available through the source's
ordinary file-data session. It requires no separate announcement command or
control channel. USB or WiFi can feed files into the source radio; receiving
radios still discover and download them over LoRa. All 136 affected native
test methods passed, including cold catalog discovery and a negative control
that removes the production readiness trigger. Canonical V4 Full and RAK3401
and RAK4631 Full/Reduced builds passed. This fixes source discovery, and does
not establish a fix for the separate Tower manifest timeout.
The V4 source subsequently accepted one application-only WiFi upload of this
commit. Authenticated GET and POST both returned HTTP 200, followed by exact
USB version/body proof (`B6E8C347E1C5FA69`) and unchanged saved preferences
and receive-power-saving configuration. This deploys the source firmware;
it is not a receiver LoRa qualification result. The initial preparation
rejected a valid start acknowledgement with a WebConfig-status suffix before
any upload; the corrected continuation did not replay the start command.
During RAK4631 discovery, source receive power saving was on and discovery
timed out. After disabling that setting under a restoration intent, a fresh
advert was received. This sequence records an observation; it does not
@@ -211,12 +269,28 @@ did not support the proposed clock or access-control explanation for the
rehearsal failure. The relevant handlers were the same; no clock or ACL
cause is claimed from this review.
A later local-control diagnostic made the signed full catalog visible, but
both matching pull attempts remained at manifest timeout, 0/0 blocks. No
install was requested. This local-control attempt is not a wireless-only
qualification pass. Its temporary radio and saved receive-power-saving
settings were restored and its USB port closed. One restoration command was
explicitly rejected with a misleading "unsupported" response; source review
shows that this response also covers a busy or failed reconfiguration. A
fresh identity/settings check followed by one new, acknowledged restoration
attempt proved the original configuration. No particular runtime guard is
identified as the cause of that rejection.
## Limits and completion
The MeshTower signed full-image LoRa case, the targeted live cold-idle RAK
preflight check and final source cleanup remain pending. Newly requested
The MeshTower signed full-image LoRa case and final source cleanup remain
pending. The live cold-idle RAK preflight check is complete. Newly requested
two-frequency ESP-NOW bridged one-hop OTA testing and latest RAK3401 CPU
testing also remain pending. The first-command USB bootstrap observation,
testing also remain pending. Receiver update setup, progress, installation
and post-install verification in the new radio campaign must use LoRa;
receiver USB commands cannot substitute for failed radio communication.
The RAK3401's current-image pause snapshot recorded historical channel-busy
bitmask `0x02`; it was retained without clearing statistics or claiming a
CPU-sleep result. The first-command USB bootstrap observation,
Tower SF5 preflight failure, later full-image self-proof failures and separate
USB open failure remain unresolved. The Tower signed delta, RAK4631 and XIAO
nRF52 full QSPI LoRa updates and their later postchecks are complete; the
+7
View File
@@ -189,6 +189,13 @@ published hardware/logging/mode intent and protects a possibly active reader.
This temporary fallback cannot authorize a reset. The separate awake hint keeps
an already durable watchdog policy serviced while publication is busy.
On nRF52, the CPU can wait for interrupts between watchdog checks in both
`on` and `auto` modes. Its clock and wake interrupts continue running, so this
does not restart recovery deadlines or disable the watchdog. Active USB work
and unfinished transport detach/reattach still prevent that idle wait. Other
platforms retain their existing sleep guard, including protection against
sleep modes that restart the application clock.
## Verification
The two qualification stages below precede the subsequent upstream USB/GPS
+8
View File
@@ -262,6 +262,13 @@ UsbLoggingStatus usbLoggingStatus() {
bool isUsbLoggingWatchdogArmed() {
if (isUsbLoggingTransportRecoveryPending()) return true;
#if defined(NRF52_PLATFORM)
// nRF52 only waits for the next interrupt; its RTC/USB/radio wake sources
// keep the loop and elapsed-millis watchdog deadlines running. A saved On
// policy or confirmed Auto reader is not continuous CPU work. Keep actual
// detach/reattach work awake above without spinning between policy ticks.
return false;
#else
// This is only an awake/service hint, never reset authorization. Keep a
// previously durable policy awake if publication is busy; falling back to
// an unavailable snapshot here could allow sleep to restart its deadline.
@@ -272,6 +279,7 @@ bool isUsbLoggingWatchdogArmed() {
const bool ready = (flags & (1U | 2U | 256U)) == (1U | 2U | 256U);
return ready && ((flags & 4U)
|| ((flags & 512U) && (flags & 1024U) && (busy || stage < 2)));
#endif
}
void loadUsbLoggingWatchdog(FILESYSTEM* fs, bool durable, uint32_t (*epoch_seconds)()) {
+2
View File
@@ -18,6 +18,8 @@ bool handleUsbLoggingWatchdogCommand(const char* command, char* reply,
// and dirty-contact ownership and is rechecked after the durable tier commit.
// True authorizes the caller's board.reboot(); this helper never reboots.
bool serviceUsbLoggingWatchdog(bool (*safe)(void*), void* context = nullptr);
// Awake/service hint, not reset authorization. nRF52's interrupt wait keeps
// policy time running, so only unfinished USB transport recovery inhibits it.
bool isUsbLoggingWatchdogArmed();
bool isUsbLoggingWatchdogUpdateActive();
+158
View File
@@ -0,0 +1,158 @@
"""Run the nRF52 idle hint with the real watchdog and role sleep guards."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/usb_logging_watchdog"
class Nrf52UsbWatchdogSleepTest(unittest.TestCase):
def test_idle_roles_keep_watchdog_deadlines_and_pending_usb_quarantine(self):
source = (ROOT / "src/helpers/UsbLoggingWatchdog.cpp").read_text(encoding="ascii")
# The persistence boundary uses the existing ESP host fake. Compile
# the exact nRF52 hint separately in the same TU, with the real policy,
# publication, staged recovery and persistent state beneath it.
hint = extract_braced(source, "bool isUsbLoggingWatchdogArmed()")
hint = hint.replace("isUsbLoggingWatchdogArmed()", "nrf52UsbNeedsService()", 1)
guards = []
for role in ("simple_repeater", "simple_room_server", "simple_sensor"):
main = (ROOT / f"examples/{role}/main.cpp").read_text(encoding="ascii")
start = main.index(" const auto sleep_guard =")
guard = main[start:main.index(" if (can_power_save)", start)]
guards.append("static bool " + role + "() {\n" + guard
+ "\nreturn can_power_save;\n}\n")
main = (ROOT / "examples/companion_radio/main.cpp").read_text(encoding="ascii")
start = main.index(" using mesh::powerdiag::Reason;", main.index("void loop()"))
guard = main[start:main.index(" uint32_t noise_floor_delay_ms = 0;", start)]
guards.append("static bool companion() {\n" + guard + "\nreturn sleep_reason == 0;\n}\n")
guards = "\n".join(guards).replace("mesh::isUsbLoggingWatchdogArmed()", "mesh::nrf52UsbNeedsService()")
harness = r'''
#include <Arduino.h>
#include <FS.h>
#include <helpers/UsbLoggingWatchdog.cpp>
#include <helpers/PowerSavingDiagnostics.h>
#include <cassert>
#include <cstring>
#include <vector>
static mesh::UsbLoggingObservation observation{true, false, false, false, 0};
static bool logging = true, pending = false, client = false, safe = true;
static std::vector<unsigned> recoveries;
namespace mesh {
bool isUsbLoggingEnabled() { return logging; }
bool isUsbLoggingClientActive() { return client; }
void clearUsbLoggingClientActivity() { client = false; }
bool isUsbLoggingTransportRecoveryPending() { return pending; }
UsbLoggingObservation observeUsbLoggingTransport() { return observation; }
UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage) {
recoveries.push_back(stage); return UsbLoggingRecoveryResult::Attempted;
}
void probeUsbLoggingTransport() {}
}
#define NRF52_PLATFORM 1
#undef ESP32_PLATFORM
namespace mesh {
@HINT@
namespace wireless {
struct Control { bool busy = false; bool pending() const { return busy; } };
Control& control() { static Control value; return value; }
}
}
static bool morse_timing = false;
struct Prefs { bool powersaving_enabled = true; };
struct Mesh {
Prefs prefs; bool work = false;
Prefs* getNodePrefs() { return &prefs; }
bool isTrackerModeEnabled() const { return false; }
mesh::powerdiag::Mask pendingWorkReason() const {
return work ? mesh::powerdiag::mask(mesh::powerdiag::Reason::Ota) : 0;
}
} the_mesh;
struct Board {
bool connected = false;
bool isUsbDataConnected() const { return connected; }
bool isUsbHostConnected() const { return connected; }
} board;
@GUARDS@
static bool recovery_safe(void*) { return safe; }
static bool service(uint32_t now) {
mock_watchdog_millis = now;
return mesh::serviceUsbLoggingWatchdog(recovery_safe);
}
static void mode(const char* command) {
char reply[160];
assert(mesh::handleUsbLoggingWatchdogCommand(command, reply, sizeof(reply)));
assert(std::strstr(reply, "(saved)"));
recoveries.clear();
}
static void all_idle() {
assert(!mesh::nrf52UsbNeedsService() && "armed watchdog must not spin idle nRF52");
assert(simple_repeater() && simple_room_server() && simple_sensor() && companion());
}
static void all_busy() {
assert(!simple_repeater() && !simple_room_server() && !simple_sensor() && !companion());
}
int main() {
fs::FS fs; fs::stat_filesystem = &fs;
mesh::loadUsbLoggingWatchdog(&fs);
mode("set usb.watchdog on");
assert(!service(0)); all_idle();
// CPU waits can leave long gaps between loop calls. They must not restart
// or pause the five-minute repair and original one-hour reset deadlines.
assert(!service(299999)); all_idle(); assert(recoveries.empty());
assert(!service(300000)); all_idle(); assert((recoveries == std::vector<unsigned>{1}));
assert(!service(360000)); all_idle(); assert((recoveries == std::vector<unsigned>{1, 2}));
assert(!service(3599999)); all_idle();
assert(service(3600000));
assert(mesh::usbLoggingStatus().reboot_count == 1);
// Mounted USB with its handle closed has the same idle behavior. A real
// open session, OTA owner, or pending detach still keeps its existing veto.
mode("set usb.watchdog on");
observation = {true, true, false, false, 1};
assert(!service(3600001)); all_idle();
board.connected = true; all_busy(); board.connected = false;
the_mesh.work = true; assert(!companion()); the_mesh.work = false;
// Auto has confirmed this boot's stats client, matching the field failure.
mode("set usb.watchdog auto");
observation = {true, true, true, false, 2}; client = true;
assert(!service(3600002));
assert(mesh::usbLoggingStatus().watchdog_auto && mesh::usbLoggingStatus().logger_active);
observation.reader_connected = false;
assert(!service(3600003)); all_idle();
assert(!service(3900003)); all_idle(); assert((recoveries == std::vector<unsigned>{1}));
assert(!service(3960003)); all_idle(); assert((recoveries == std::vector<unsigned>{1, 2}));
assert(!service(7200003)); all_idle();
assert(mesh::usbLoggingStatus().reboot_count == 1); // Auto never adds a reboot.
// A real in-flight USB cleanup must finish even with the logger/watchdog off.
mode("set usb.watchdog off"); logging = false; pending = true;
assert(mesh::nrf52UsbNeedsService()); all_busy();
pending = false; all_idle();
}
'''.replace("@HINT@", hint).replace("@GUARDS@", guards)
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if compiler is None:
self.skipTest("a host C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="nrf52-watchdog-sleep-") as directory:
cpp, binary = Path(directory) / "sleep.cpp", Path(directory) / "sleep"
cpp.write_text(harness, encoding="ascii")
built = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-pthread",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-DARDUINO", "-DESP32", "-DESP32_PLATFORM", "-DARDUINO_USB_MODE=0",
"-DARDUINO_USB_CDC_ON_BOOT=1", "-I" + str(FIXTURE / "mocks"),
"-I" + str(ROOT / "src"), str(cpp), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()