Sfoglia il codice sorgente

Zephyr port: wire real setup()/loop() entry, HFXO start, handoff doc

main.cpp (ported from OpenPuck.ino) is now the Zephyr entry: main() runs
setup() once then the cooperative loop() forever on the main thread, matching
the Arduino runtime. Start HFXO explicitly for the bare-metal RADIO; drive the
WDT directly (Zephyr watchdog driver left out to avoid double-ownership).

Full firmware compiles + links + produces zephyr.uf2. USB remains inert
(usb_stub) pending the real USBD backing; see ARCHITECTURE.zephyr.md for the
exact remaining work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jari 4 mesi fa
parent
commit
8e4ae2268d

+ 78 - 0
OpenPuckZephyr/ARCHITECTURE.zephyr.md

@@ -0,0 +1,78 @@
+# OpenPuck — Zephyr RTOS port
+
+This is the Zephyr port of the OpenPuck firmware (originally Arduino /
+Adafruit-nRF52 / TinyUSB, in `../OpenPuck`). It targets mainline Zephyr
+(v4.4.99) on `adafruit_feather_nrf52840/nrf52840/uf2`.
+
+## Build
+
+```sh
+./zbuild.sh                 # uses ~/zephyrproject + ~/zephyr-sdk-1.0.1 via the venv
+# or, with env set up:
+west build -b adafruit_feather_nrf52840/nrf52840/uf2 OpenPuckZephyr
+```
+
+Output `build/zephyr/zephyr.uf2` flashes via the existing double-tap-reset DFU
+procedure (the `uf2` board variant reserves the Adafruit bootloader region and
+uses app base 0x26000).
+
+## Migration strategy: compatibility shims
+
+The reverse-engineered radio/protocol/mode logic is the value of this firmware
+and the part most dangerous to rewrite. So instead of rewriting it, the port
+keeps those ~8000 lines near-verbatim and supplies a thin compatibility layer in
+`src/compat/`:
+
+| Shim | Replaces | Backing |
+|---|---|---|
+| `Arduino.h` / `arduino_compat.cpp` | Arduino core | `micros()` via Cortex-M DWT cycle counter; `millis()`/`delay()` via kernel; GPIO via `nrf_gpio`; `Serial` via console; `NRF_*` registers + CMSIS via nrfx MDK (`<nrfx.h>`) |
+| `Adafruit_LittleFS.h` / `InternalFileSystem.h` / `littlefs_shim.cpp` | Adafruit LittleFS | Zephyr `fs` (LittleFS) mounted on `storage_partition` |
+| `Adafruit_TinyUSB.h` | TinyUSB / Adafruit USBD | Zephyr USBD (see USB status below) |
+
+The `NRF_RADIO`/`NRF_WDT`/`NRF_FICR`/`NRF_CLOCK` register code ports essentially
+unchanged because Zephyr's hal_nordic ships the same Nordic MDK. `CONFIG_BT`,
+`CONFIG_IEEE802154`, and the 802.15.4 radio driver are disabled so the RADIO
+peripheral is free for the bare-metal protocol.
+
+## Status
+
+**Compiles + links + produces a UF2**, with the real `setup()`/`loop()` entry
+(`main.cpp`, ported from `OpenPuck.ino`). Every module is in the build:
+radio, rf_link, rf_diag, triton, gamepad_util, haptics, controllers, puck_hid,
+all six modes (lizard, xinput, switch_hori, switch_pro, ps5, hidgyro),
+webusb_config, serial_console, usb_mount, wake_hid, config, bonds.
+
+### Functional / verified-as-compiling
+- Build pipeline, persistence (LittleFS), config + bond load/save, the radio
+  register layer, the RF protocol state machines, and all mode report-building
+  logic compile under Zephyr.
+- HFXO is started explicitly in `setup()` (the Arduino core did it implicitly);
+  the WDT is driven directly via `NRF_WDT` (Zephyr's watchdog driver is left out
+  to avoid double-ownership).
+
+### NOT yet functional (the remaining work, none hardware-verified)
+1. **USB is inert.** `usb_stub.cpp` satisfies the TinyUSB shim with safe no-ops
+   (device never mounts, HIDs never ready). The device will **not enumerate**
+   until the real Zephyr USBD backing is finished. The staged real backing lives
+   in `usb_glue.cpp` and needs:
+   - a devicetree overlay declaring N `zephyr,hid-device` nodes (the HID pool the
+     shim binds to) plus CDC ACM, MSC, and a vendor (WebUSB) interface;
+   - a `usbd_context` built per active mode (register exactly the classes that
+     mode needs at init — see the concession below);
+   - the USBD message callback wired to `opk_usb_msg` for mount/suspend state.
+2. **WebUSB** vendor pipe (`Adafruit_USBD_WebUSB`) + **MS OS 2.0** descriptors —
+   needed to preserve Windows/Chrome binding. Currently stubbed inert.
+3. **XInput** is a custom TinyUSB class driver (`mode_xinput.cpp`, via the
+   `device/usbd_pvt.h` shim). It compiles but needs a real Zephyr custom USBD
+   class to enumerate; the report-building logic is intact.
+4. **CDC console**: `serial_console` reads via the `opk_serial_*` weak hooks —
+   wire them to a Zephyr CDC ACM (or RTT) endpoint.
+5. **status_led** pin mapping: the Arduino code uses logical pin numbers; under
+   the absolute-nRF-pin `digitalWrite` shim the B-pin (P0.15) needs an override.
+
+### Behavioral concession (documented)
+Zephyr's USBD descriptor set is fixed once the stack is enabled. The Arduino
+firmware's **dynamic re-enumeration without reboot** (`usbReenumerate`, variable
+connected-controller count) does not map; it collapses to **reboot-to-
+reenumerate**, which was already the firmware's mode-switch path. Per-mode
+interface sets are achieved by registering only that mode's classes at init.

+ 1 - 1
OpenPuckZephyr/CMakeLists.txt

@@ -17,7 +17,7 @@ target_include_directories(app PRIVATE
   ${CMAKE_CURRENT_SOURCE_DIR}/src/compat)
 
 target_sources(app PRIVATE
-  src/main.c
+  src/main.cpp
 
   # compatibility layer
   src/compat/arduino_compat.cpp

+ 4 - 2
OpenPuckZephyr/prj.conf

@@ -28,9 +28,11 @@ CONFIG_REBOOT=y
 # the persistence code ports behind a thin Adafruit_LittleFS shim.
 CONFIG_FILE_SYSTEM=y
 CONFIG_FILE_SYSTEM_LITTLEFS=y
+CONFIG_FILE_SYSTEM_MKFS=y
 
-# Hardware watchdog (replaces the NRF_WDT poke in the Arduino loop()).
-CONFIG_WATCHDOG=y
+# The firmware drives the WDT peripheral directly (NRF_WDT, ported verbatim from
+# the Arduino setup()/loop()), so Zephyr's watchdog driver is left out to avoid
+# double-ownership of the peripheral.
 
 # Microsecond timing for the RF poll busy-waits comes from a dedicated 1 MHz
 # nrfx TIMER in the compat layer (the kernel RTC tick is too coarse), so the

+ 9 - 1
OpenPuckZephyr/src/compat/Adafruit_TinyUSB.h

@@ -203,10 +203,18 @@ class Adafruit_USBD_HID : public Adafruit_USBD_Interface
 };
 
 // ---- WebUSB vendor interface ----
-class Adafruit_USBD_WebUSB
+class Adafruit_USBD_WebUSB : public Adafruit_USBD_Interface
 {
     public:
 	Adafruit_USBD_WebUSB();
+	uint16_t getInterfaceDescriptor(uint8_t itfnum, uint8_t *buf,
+					uint16_t bufsize) override
+	{
+		(void)itfnum;
+		(void)buf;
+		(void)bufsize;
+		return 0;
+	}
 	bool begin();
 	bool connected();
 	int available();

+ 0 - 19
OpenPuckZephyr/src/main.c

@@ -1,19 +0,0 @@
-/*
- * OpenPuck — Zephyr port entry point (bring-up stub).
- *
- * This will grow into the cooperative pump that the Arduino loop() was. For now
- * it only proves the toolchain + board build and links.
- */
-#include <zephyr/kernel.h>
-#include <zephyr/logging/log.h>
-
-LOG_MODULE_REGISTER(openpuck, LOG_LEVEL_INF);
-
-int main(void)
-{
-	LOG_INF("OpenPuck Zephyr port: bring-up stub up");
-	while (1) {
-		k_sleep(K_SECONDS(1));
-	}
-	return 0;
-}

+ 342 - 0
OpenPuckZephyr/src/main.cpp

@@ -0,0 +1,342 @@
+// OpenPuck.ino -- Steam Controller 2 ("Triton") puck reimplementation for an nRF52840.
+//
+// This firmware impersonates the Valve puck over USB, maintains puck-style bond slots, speaks the
+// reverse-engineered RF protocol to the controller, and re-enumerates into Steam, Xbox, Switch, PS5, or DS4
+// personalities. Build with -DCFG_TUD_HID=4 (the Adafruit nRF port defaults to 2).
+//
+// This file is just the entry point: setup() builds the USB presentation for the persisted mode and arms the
+// hardware watchdog; loop() pumps each subsystem. Everything substantive lives in the modules below -- see
+// ARCHITECTURE.md for the full map. The short version:
+//   config / identity / bonds  -- persisted settings, device serial, the four controller bond slots
+//   radio / rf_link / rf_diag   -- bare-metal nRF52 RADIO, the operational puck protocol, and RE tooling
+//   triton / gamepad_util       -- the decoded controller input (g_in) + shared host-report build helpers
+//   controllers + mode_*        -- one IController per USB personality (puck/lizard, xbox, switch, ps5, ds4)
+//   haptics / webusb_config / serial_console -- haptic relay, the browser config channel, the CDC debug CLI
+//
+// Protocol reference: docs/PROTOCOL.md.  Bond record = [8 uuid][16 serial].
+#include <zephyr/kernel.h>
+#include <Adafruit_TinyUSB.h>
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+#include "config.h"
+
+// OPK_GIT_HASH: tags the one-time factory-reset build
+#include "build_info.h"
+#include "identity.h"
+#include "bonds.h"
+#include "radio.h"
+#include "controllers.h"
+#include "haptics.h"
+#include "rf_link.h"
+#include "rf_diag.h"
+#include "webusb_config.h"
+#include "serial_console.h"
+#include "wake_hid.h"
+#include "status_led.h"
+#include "usb_mount.h"
+#include "identity.h"
+#include <stdio.h>
+
+#if CFG_TUD_HID < 4
+#error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
+#endif
+
+// puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
+static uint8_t g_usbCfgDesc[512];
+
+// Per-mode USB serial suffix (modes 1..8: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game D=ds4game).
+static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D' };
+// Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
+static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
+
+// Dynamic re-enumeration (NO MCU reboot): tear down + rebuild the config descriptor presenting `k` connected-
+// controller slots, replaying the fixed interfaces in locked HID-instance order (wake mouse = instance 0,
+// before the slot pool; WebUSB is vendor-class so order-free), then re-attach. RF/firmware state survives.
+// g_usbToBond must already be built for k connected controllers. Called at boot and by usb_mount's watcher.
+void usbReenumerate(uint8_t k)
+{
+	USBDevice.detach();
+	delay(20);
+	USBDevice.clearConfiguration();
+	USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
+	g_active->usbIdentity(); // clearConfiguration reset VID/PID/strings -- restore them
+	// serial carries the mounted count so the host invalidates its cached config descriptor on a change
+	snprintf(
+		g_usbSerial, sizeof g_usbSerial, "%s%c%u", g_unit,
+		MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= 8) ? g_usbMode - 1 :
+								 0],
+		(unsigned)k);
+	USBDevice.setSerialDescriptor(g_usbSerial);
+	if (s_dynWantWakeMouse)
+		wakeHidAddInterface(); // HID instance 0
+	g_active->mountSlots(
+		k); // mode's fixed HIDs (if any) + k slot interfaces
+	if (s_dynWantWebusb)
+		USBDevice.addInterface(usb_web);
+	USBDevice.setConfigurationAttribute(0x80 | 0x20);
+	USBDevice.attach();
+}
+
+void setup()
+{
+	// The bare-metal RADIO needs the high-frequency crystal (HFXO) for accurate
+	// RF; the Adafruit core started it implicitly. Start it explicitly here.
+	// (USB also forces HFXO on, but the radio may run before/without USB.)
+	NRF_CLOCK->EVENTS_HFCLKSTARTED = 0;
+	NRF_CLOCK->TASKS_HFCLKSTART = 1;
+	for (uint32_t w = micros(); !NRF_CLOCK->EVENTS_HFCLKSTARTED &&
+				    (uint32_t)(micros() - w) < 2000;)
+		;
+
+	genSerial();
+	ledInit();
+
+	// seed defaults so unbonded slots don't share the discovery address
+	for (int s = 0; s < NSLOT; s++)
+		rfGenSessionAddr(s);
+	InternalFS.begin();
+#if OPK_FACTORY_RESET
+	// Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
+	// then persist normally (tracked by a git-hash tag so it does NOT wipe every boot). See config.h / config.cpp.
+	factoryResetOnce(OPK_GIT_HASH);
+#endif
+	loadCfg();
+	loadBonds();
+	// regenerate per-slot session addresses from each bond UUID (deterministic, stable across reboots)
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used)
+			rfGenSessionAddr(s);
+
+	// decide USB presentation BEFORE registering interfaces
+	g_xbox = !modeIsPuck(g_usbMode);
+	g_active = controllerFor(g_usbMode);
+
+	// ---- USB descriptor rebuild ----
+	// Puck mode drops the CDC serial console (clearConfiguration) to free a USB endpoint for the wake-mouse
+	// interface that wakes a sleeping Windows host. The one-shot debug arm (g_debugCdcThisBoot, set via WebUSB
+	// panel / CDC 'D', auto-reverting after one boot) instead KEEPS the boot CDC composite and skips the wake
+	// mouse (no endpoint room for both). Clean modes always rebuild bare.
+	const bool puckMode = g_active->isPuck();
+	const bool keepCdc = puckMode && g_debugCdcThisBoot;
+	// The "game" PlayStation modes enumerate as a CLEAN single HID gamepad so games that classify off the raw HID
+	// device (Fortnite/UE GameInput, Windows.Gaming.Input) recognise them as PlayStation. A real Sony pad is just
+	// the gamepad HID (+ a USB-audio interface); our extra wake-mouse HID and WebUSB vendor interface make those
+	// classifiers refuse the PS glyph path even with the correct VID/PID (SDL/Steam tolerate the extras). So
+	// clean-PS modes skip BOTH the wake mouse and WebUSB -- no config panel / host-wake; chord back to Steam
+	// (back-paddle 4 + A) to reach the panel. Normal MODE_PS5 / MODE_HIDGYRO keep wake + panel.
+	const bool psClean = modeIsCleanPS(g_usbMode);
+	const bool dynamic = g_active->dynamicMount();
+
+	if (dynamic) {
+		// Dynamic mount: present only ACTIVELY-CONNECTED controllers; usbReenumerate re-attaches (no reboot)
+		// as the set changes. Emulated modes are never puck; clean-PS drops the wake mouse + WebUSB.
+		s_dynWantWakeMouse = !psClean;
+		s_dynWantWebusb = !psClean;
+		USBDevice.detach();
+		delay(30);
+		USBDevice.clearConfiguration();
+		USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+						 sizeof g_usbCfgDesc);
+		// Lock TinyUSB HID instance indices ONCE, canonical order: wake mouse (HID 0) then the mode's pool.
+		if (s_dynWantWakeMouse)
+			wakeHidBegin();
+		g_active->beginPool();
+		if (s_dynWantWebusb)
+			usb_web.begin();
+		usbMountEnable(true, g_active->maxSlots());
+		usbMountRebuildMap(); // initial connected set (usually empty at cold boot)
+		usbReenumerate(
+			g_usbMountCount); // build the live descriptor + attach
+	} else {
+		USBDevice.detach();
+		delay(30);
+		if (keepCdc) {
+			USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+							 sizeof g_usbCfgDesc);
+		} else {
+			USBDevice.clearConfiguration();
+
+			// headroom over the default 256 B cap
+			USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+							 sizeof g_usbCfgDesc);
+		}
+
+		// Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
+		// identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
+		// identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
+		if (puckMode) {
+			USBDevice.setSerialDescriptor(g_unit);
+		} else {
+			// Bond count in serial so Windows invalidates its cached config descriptor if the count changes.
+			snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%d",
+				 g_unit, MODE_SUFFIX[g_usbMode - 1],
+				 bondedSlotCount() > 0 ? bondedSlotCount() : 1);
+			USBDevice.setSerialDescriptor(g_usbSerial);
+		}
+
+		// SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
+		// and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
+		if (puckMode)
+			usb_web.begin();
+		if (puckMode && !keepCdc)
+			wakeHidBegin();
+
+		g_active->begin();
+
+		// Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
+		// no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
+		// modes so the device stays a single clean HID gamepad (see psClean above).
+		if (!puckMode && !keepCdc && !psClean)
+			wakeHidBegin();
+
+		// WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
+		// slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
+		if (!puckMode && !psClean)
+			usb_web.begin();
+		// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
+		USBDevice.setConfigurationAttribute(0x80 | 0x20);
+
+		// re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
+		USBDevice.attach();
+	}
+	Serial.begin(115200);
+	for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
+		delay(10); // wait up to 3s for USB mount, but NEVER hang
+	if (USBDevice.suspended()) {
+		USBDevice.remoteWakeup();
+		ledWakePulse();
+	} // wake host if bus was sleeping when we (re-)attached
+	hapticInit();
+	static const char *MODE_NAME[] = {
+		"STEAM(puck)",	       "XBOX(xinput+mouse)",
+		"SWITCH(horipad)",     "LIZARD(puck kb/mouse)",
+		"SWITCH(pro+gyro)",    "PS5(dualsense)",
+		"HIDGYRO(ds4+motion)", "PS5(dualsense,game/clean)",
+		"DS4(ds4,game/clean)"
+	};
+	Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
+		      g_board,
+		      MODE_NAME[g_usbMode <= MODE_MAX ? g_usbMode : 0]);
+	if (puckMode)
+		Serial.printf(
+			"# puck USB: %s\n",
+			keepCdc ?
+				"DEBUG boot (CDC console on, wake mouse off; reverts next boot)" :
+				"normal (CDC off, wake mouse on)");
+	Serial.printf(
+		"# session ch%u (discovery on ibex/ch2); per-slot session addrs:\n",
+		g_sessCh);
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used) {
+			Serial.printf(
+				"#   slot %d: %02X%02X%02X%02X/%02X (uuid %02X%02X%02X%02X %02X%02X%02X%02X)\n",
+				s, g_sessBase[s][0], g_sessBase[s][1],
+				g_sessBase[s][2], g_sessBase[s][3],
+				g_sessPrefix[s], g_slot[s].rec[0],
+				g_slot[s].rec[1], g_slot[s].rec[2],
+				g_slot[s].rec[3], g_slot[s].rec[4],
+				g_slot[s].rec[5], g_slot[s].rec[6],
+				g_slot[s].rec[7]);
+		}
+	}
+	// Hardware watchdog: if loop() ever stops feeding it (wedged radio busy-wait, HardFault spin, blocked CDC
+	// write) the WDT resets the nRF52 after ~8s, re-enumerating USB + re-initialising RF, so a hang no longer
+	// needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.
+	NRF_WDT->CONFIG = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) |
+			  (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
+	NRF_WDT->CRV = 8UL * 32768UL - 1; // timeout in 32.768 kHz ticks (~8 s)
+	NRF_WDT->RREN = WDT_RREN_RR0_Msk; // arm reload register 0
+	NRF_WDT->TASKS_START = 1;
+}
+
+// loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only
+// fires as often as the loop comes around). Per-section us accumulates and each second the avg loop period +
+// slowest section are published for the WebUSB panel.
+
+// avg loop iteration time last second (1e6/iterations)
+uint16_t g_loopPeriodUs = 0;
+
+// index of the slowest section: 0=webusb 1=ctrl.task 2=serial 3=rfdiag 4=rflink 5=haptic 6=led
+uint8_t g_loopWorst = 0;
+uint16_t g_loopWorstUs = 0; // that section's avg us per iteration
+void loop()
+{
+	// feed the watchdog; if we ever stop, the ~8s WDT auto-resets us
+	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
+	if (g_dirty) {
+		g_dirty = false;
+		saveBonds();
+	}
+#if OPK_LOG
+	// Diagnostic build: time each loop section so the panel can show the slowest one + the loop period.
+	static uint32_t acc[7] = { 0 };
+	static uint32_t loops = 0;
+	static unsigned long secMs = 0;
+	uint32_t t;
+	t = micros();
+	webusbPoll();
+	acc[0] += (uint32_t)(micros() - t);
+	t = micros();
+	if (g_active)
+		g_active->task();
+	acc[1] += (uint32_t)(micros() - t);
+	t = micros();
+	serialConsolePoll();
+	acc[2] += (uint32_t)(micros() - t);
+	t = micros();
+	rfDiagTask();
+	acc[3] += (uint32_t)(micros() - t);
+	t = micros();
+	rfLinkTask();
+	acc[4] += (uint32_t)(micros() - t);
+	t = micros();
+	hapticTask();
+	acc[5] += (uint32_t)(micros() - t);
+	t = micros();
+	ledTask();
+	acc[6] += (uint32_t)(micros() - t);
+	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+	loops++;
+	if (millis() - secMs >= 1000) {
+		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
+		uint8_t wi = 0;
+		uint32_t wv = 0;
+		for (int i = 0; i < 7; i++) {
+			if (acc[i] > wv) {
+				wv = acc[i];
+				wi = i;
+			}
+			acc[i] = 0;
+		}
+		g_loopWorst = wi;
+		g_loopWorstUs = loops ? (uint16_t)(wv / loops) : 0;
+		loops = 0;
+		secMs = millis();
+	}
+#else
+	webusbPoll();
+	if (g_active)
+		g_active->task();
+	serialConsolePoll();
+	rfDiagTask();
+	rfLinkTask();
+	hapticTask();
+	ledTask();
+	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
+#endif
+}
+
+// Zephyr entry. The Arduino runtime called setup() once then loop() forever; we
+// do the same on the main thread. The loop is intentionally tight (no sleep):
+// the RF poll cadence (~250 Hz) depends on coming around quickly, exactly as on
+// the Arduino core. USB servicing runs in the USBD stack thread + ISRs, so the
+// tight main loop does not starve it.
+int main(void)
+{
+	setup();
+	for (;;)
+		loop();
+	return 0;
+}