From ce46e60a861ee6b710599bfcdf680e2eec23c6b0 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 25 Sep 2026 17:41:06 -0700 Subject: [PATCH] Replay initial nRF52 USB READY after application handoff --- docs/nrf52-usb-ready-fix.md | 7 ++++- scripts/nrf52_usb_power_fix.py | 38 +++++++++++++++++++++++++-- test/test_nrf52_usb_power.py | 48 ++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/docs/nrf52-usb-ready-fix.md b/docs/nrf52-usb-ready-fix.md index 0a6c1a40..63e7cd6a 100644 --- a/docs/nrf52-usb-ready-fix.md +++ b/docs/nrf52-usb-ready-fix.md @@ -24,6 +24,10 @@ framework implementation stops the build for review. The backport: - rechecks attachment while waiting, including completion by another callback; - bounds both waits with one 100,000-poll budget, independent of OS ticks; - ignores stale READY on a disabled peripheral and exits if it is removed. +- replays an already asserted regulator READY state when the application USB + task starts with VBUS present, as the bootloader does. The framework's + original startup replayed DETECTED only, so a missing later power edge could + leave the new application's USB transport unattached after a UF2 handoff. The poll budget is an iteration limit, not a promised elapsed-time timeout. If the clock/peripheral never becomes ready, the caller returns instead of @@ -36,6 +40,7 @@ patched handler from the pinned-framework fixture, model W1C event semantics, and cover duplicate/nested callbacks, delayed/missing clocks and READY, removal, retry, and detached USB. Restoring the inherited READY prefix must reproduce the infinite wait under a subprocess deadline. Additional tests check SDK -isolation, idempotence, fail-closed patching, and all nRF52 environment hooks. +isolation, idempotence, fail-closed patching, the startup DETECTED/READY order, +and all nRF52 environment hooks. The harness is shared with OTAFIX's TinyUSB fork at `test/otafix/nrf5x_power_test.py`; keep the two copies in sync when extending it. diff --git a/scripts/nrf52_usb_power_fix.py b/scripts/nrf52_usb_power_fix.py index 52252a49..9932652b 100644 --- a/scripts/nrf52_usb_power_fix.py +++ b/scripts/nrf52_usb_power_fix.py @@ -1,4 +1,4 @@ -"""Compile a build-local fix for the framework's inherited nRF52 USB READY hang. +"""Compile build-local fixes for the framework's nRF52 USB power startup. Never modify PlatformIO's shared framework package. Fail closed if a framework update changes the code this narrow backport expects. The same handler policy @@ -60,6 +60,22 @@ ATTACH = """ // Enable pull up case USB_EVT_REMOVED:""" +OLD_PORT_INIT = """ if (usb_reg & POWER_USBREGSTATUS_VBUSDETECT_Msk) { + tusb_hal_nrf_power_event(NRFX_POWER_USB_EVT_DETECTED); + } +} +""" +PORT_INIT = """ if (usb_reg & POWER_USBREGSTATUS_VBUSDETECT_Msk) { + tusb_hal_nrf_power_event(NRFX_POWER_USB_EVT_DETECTED); + } + // On an application handoff, VBUS and the regulator may already be ready. + // The power driver only reports future edges, so replay both initial states. + if (usb_reg & POWER_USBREGSTATUS_OUTPUTRDY_Msk) { + tusb_hal_nrf_power_event(NRFX_POWER_USB_EVT_READY); + } +} +""" + def patched_source(source): source = source.replace("\r\n", "\n") @@ -73,10 +89,24 @@ def patched_source(source): return source.replace(OLD_READY, READY).replace(OLD_ATTACH, ATTACH) +def patched_port_source(source): + source = source.replace("\r\n", "\n") + if PORT_INIT in source and OLD_PORT_INIT not in source: + return source + if source.count(OLD_PORT_INIT) != 1: + raise RuntimeError( + "nRF52 USB power fix: unrecognized USB startup; review the " + "framework update before building (shared SDK was not modified)" + ) + return source.replace(OLD_PORT_INIT, PORT_INIT) + + def replace_driver(build_env, node): # SCons passes a not-yet-created VariantDir node, not the SDK source path. source = Path(node.srcnode().get_abspath()) - patched = patched_source(source.read_text(encoding="utf-8")) + patcher = (patched_port_source if source.name == "Adafruit_TinyUSB_nrf.cpp" + else patched_source) + patched = patcher(source.read_text(encoding="utf-8")) destination = Path(build_env.subst("$BUILD_DIR")) / "patched-nrf52-usb" / source.name destination.parent.mkdir(parents=True, exist_ok=True) if not destination.exists() or destination.read_text(encoding="utf-8") != patched: @@ -90,6 +120,10 @@ def install(build_env): replace_driver, "*Adafruit_TinyUSB_Arduino*src*portable*nordic*nrf5x*dcd_nrf5x.c", ) + build_env.AddBuildMiddleware( + replace_driver, + "*Adafruit_TinyUSB_Arduino*src*arduino*ports*nrf*Adafruit_TinyUSB_nrf.cpp", + ) if "Import" in globals(): diff --git a/test/test_nrf52_usb_power.py b/test/test_nrf52_usb_power.py index 86c141b7..4ebfd016 100644 --- a/test/test_nrf52_usb_power.py +++ b/test/test_nrf52_usb_power.py @@ -4,6 +4,8 @@ import configparser import importlib.util from pathlib import Path +import shutil +import subprocess import tempfile import unittest @@ -15,6 +17,32 @@ SPEC = importlib.util.spec_from_file_location("usb_fix", ROOT / "scripts/nrf52_u FIX = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(FIX) ORIGINAL = (ROOT / "test/fixtures/nrf52_usb_power_original.c").read_text() +PORT_SOURCE = """ +typedef struct { unsigned USBREGSTATUS; } PowerRegisters; +static PowerRegisters registers; +#define NRF_POWER (®isters) +#define POWER_USBREGSTATUS_VBUSDETECT_Msk 1u +#define POWER_USBREGSTATUS_OUTPUTRDY_Msk 2u +enum { NRFX_POWER_USB_EVT_DETECTED = 0, NRFX_POWER_USB_EVT_READY = 2 }; +static unsigned events[2], event_count; +static void tusb_hal_nrf_power_event(unsigned event) { events[event_count++] = event; } +static void usb_hardware_init(void) { + unsigned usb_reg = NRF_POWER->USBREGSTATUS; + if (usb_reg & POWER_USBREGSTATUS_VBUSDETECT_Msk) { + tusb_hal_nrf_power_event(NRFX_POWER_USB_EVT_DETECTED); + } +} +int main() { + for (unsigned status = 0; status < 4; ++status) { + registers.USBREGSTATUS = status; + event_count = 0; + usb_hardware_init(); + if (event_count != ((status & 1u) != 0u) + ((status & 2u) != 0u)) return 1; + if (status & 1u && events[0] != NRFX_POWER_USB_EVT_DETECTED) return 2; + if (status & 2u && events[event_count - 1] != NRFX_POWER_USB_EVT_READY) return 3; + } +} +""" class UsbPowerTests(unittest.TestCase): @@ -26,6 +54,26 @@ class UsbPowerTests(unittest.TestCase): self.assertEqual(fixed, FIX.patched_source(ORIGINAL.replace("\n", "\r\n"))) self.assertEqual(fixed, FIX.patched_source(fixed)) + def test_initial_ready_is_replayed_after_detected(self): + compiler = shutil.which("c++") + if compiler is None: + self.skipTest("C++ compiler unavailable") + patched = FIX.patched_port_source(PORT_SOURCE) + self.assertEqual(patched, FIX.patched_port_source(patched)) + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "usb_port.cpp" + program = Path(directory) / "usb_port" + source.write_text(patched) + subprocess.run([compiler, "-std=c++11", "-o", str(program), str(source)], + check=True, capture_output=True) + subprocess.run([str(program)], check=True, capture_output=True) + + def test_unrecognized_usb_port_fails_closed(self): + for source in ("", PORT_SOURCE.replace("USBREGSTATUS", "STATUS"), + PORT_SOURCE + PORT_SOURCE): + with self.subTest(source=source[:40]), self.assertRaises(RuntimeError): + FIX.patched_port_source(source) + def test_unrecognized_or_partly_fixed_driver_fails_closed(self): for source in ("", ORIGINAL.replace("hfclk_running()", "clock_running()"), ORIGINAL + ORIGINAL, ORIGINAL.replace(FIX.OLD_READY, FIX.READY)):