소스 검색

RF recovery: reconcile with current upstream main (#273)

Reconcile the hardware-validated RF recovery work with current upstream main. Preserve current upstream Steam-Machine, Triton/SDL, PlayStation, and Switch-Pro behavior while retaining the RF recovery, journal, Builder, persistence, and WebUSB features. The WebUSB capability marker moves to an append-only v22 byte so upstream p[181] remains the Steam-Machine toggle.

Co-authored-by: OpenPuck reconciliation <314675972+ixelyth@users.noreply.github.com>
Edgar 1 주 전
부모
커밋
d2f758173d
17개의 변경된 파일과 3953개의 추가작업 그리고 108개의 파일을 삭제
  1. 3 0
      ARCHITECTURE.md
  2. 3 2
      Makefile
  3. 1 0
      OpenPuck/OpenPuck.ino
  4. 30 3
      OpenPuck/config.cpp
  5. 4 0
      OpenPuck/config.h
  6. 13 3
      OpenPuck/fw_update.cpp
  7. 39 0
      OpenPuck/haptics.cpp
  8. 6 0
      OpenPuck/haptics.h
  9. 4 2
      OpenPuck/puck_hid.cpp
  10. 26 0
      OpenPuck/radio.cpp
  11. 3 0
      OpenPuck/radio.h
  12. 22 0
      OpenPuck/rf_journal_layout.h
  13. 3146 88
      OpenPuck/rf_link.cpp
  14. 99 0
      OpenPuck/rf_link.h
  15. 2 3
      OpenPuck/serial_console.cpp
  16. 136 5
      OpenPuck/webusb_config.cpp
  17. 416 2
      docs/index.html

+ 3 - 0
ARCHITECTURE.md

@@ -180,6 +180,9 @@ Two files in the nRF52 internal LittleFS:
 - `cfg.bin` (`config.cpp`) — USB mode, tunables, chord assignments, persistence policy. A magic byte versions
   the layout; a mismatch falls back to clean defaults.
 - `bonds.bin` (`bonds.cpp`) — the four bond records.
+- The RF channel-history journal uses two CRC/commit-protected flash pages at `0x86000–0x87FFF`. Official
+  application builds are link-guarded below that window and WebUSB update staging starts above it, so ordinary
+  firmware updates preserve learned RF history. A full-board wipe intentionally clears the journal too.
 
 Mode switches (chord, WebUSB, or CDC) call `saveMode()` then reboot so the next boot enumerates the right
 interface set. By default every cold boot returns to Steam mode unless "persist last mode" is enabled.

+ 3 - 2
Makefile

@@ -45,9 +45,10 @@ EXTRA_FLAGS ?=
 # already defines to serve WebUSB. Original Xbox mode needs the XID requests that arrive on it.
 # Overriding the weak symbol would claim the whole hook and force a copy of Adafruit's WebUSB body,
 # so we wrap: webusb_config.cpp takes XID and passes everything else to __real_.
-OPENPUCK_LINK_FLAGS ?= -Wl,--wrap=tud_vendor_control_xfer_cb
+RF_JOURNAL_BASE ?= 0x86000
+OPENPUCK_LINK_FLAGS ?= -Wl,--wrap=tud_vendor_control_xfer_cb -Wl,--section-start=.rf_journal_guard=$(RF_JOURNAL_BASE)
 # {build.flags.usb} is expanded by arduino-cli (VID/PID/strings); pass it through verbatim.
-USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
+USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) -DOPK_RF_JOURNAL_BASE=$(RF_JOURNAL_BASE) $(EXTRA_FLAGS)
 # When BUILD_PATH is set, --clean + path flags are injected; omitted for fast incremental dev builds.
 _PATH_FLAGS = $(if $(BUILD_PATH),--clean --build-path $(BUILD_PATH) --output-dir $(OUTPUT_DIR))
 

+ 1 - 0
OpenPuck/OpenPuck.ino

@@ -151,6 +151,7 @@ void setup()
 	factoryResetOnce(OPK_GIT_HASH);
 #endif
 	loadCfg();
+	rfApplyStartupLastGoodChannel();
 	loadBonds();
 	// Lizard (desktop) keyboard/mouse binding table. Custom remapping applies ONLY in pure
 	// MODE_LIZARD: there we install the user's saved/editable map. In every other mode the

+ 30 - 3
OpenPuck/config.cpp

@@ -111,6 +111,7 @@ struct Cfg {
 	// autonomous controller power-off on host sleep (see haptics.h g_suspendOff). 0/1; 0xFF (short
 	// pre-tail file) -> compiled default (on)
 	uint8_t suspendOff;
+	uint8_t ext[CFG_EXT_STORAGE_BYTES];
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 // Shortest cfg.bin we still accept: the layout as of CFG_MAGIC 0xCF, i.e. everything before the appended tail.
@@ -118,6 +119,21 @@ struct Cfg {
 // tail field treats as "unset" and replaces with its default.
 #define CFG_LEN_MIN (offsetof(struct Cfg, chordDpad))
 
+static uint8_t g_cfgExt[CFG_EXT_STORAGE_BYTES] = {
+	0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
+};
+
+uint8_t cfgExtRead(uint8_t index)
+{
+	return index < CFG_EXT_STORAGE_BYTES ? g_cfgExt[index] : 0xFFu;
+}
+
+void cfgExtWrite(uint8_t index, uint8_t value)
+{
+	if (index < CFG_EXT_STORAGE_BYTES)
+		g_cfgExt[index] = value;
+}
+
 void saveCfg()
 {
 	Cfg c = { CFG_MAGIC,
@@ -138,7 +154,9 @@ void saveCfg()
 		    g_chordDpad[3] },
 		  {},
 		  g_rumbleStyle,
-		  g_suspendOff };
+		  g_suspendOff,
+		  {} };
+	memcpy(c.ext, g_cfgExt, sizeof c.ext);
 	for (int i = 0; i < ET_COUNT; i++) {
 		c.type[i] = g_type[i];
 		c.padStick[i][0] = g_padStickCfg[i][0];
@@ -155,6 +173,7 @@ void saveCfg()
 void loadCfg()
 {
 	Cfg c;
+	memset(g_cfgExt, 0xFF, sizeof g_cfgExt);
 	// 0xFF prefill: bytes a SHORT (pre-tail) cfg.bin never wrote stay 0xFF, which is not a valid mode/flag, so
 	// each tail field below falls back to its compiled default instead of reading whatever was on the stack.
 	memset(&c, 0xFF, sizeof c);
@@ -166,6 +185,13 @@ void loadCfg()
 		// predating the tail keeps mode/paddles/chords instead of silently reverting to factory defaults.
 		// Anything shorter, or a stale magic, is a real layout change -> discard and use defaults.
 		if (got >= (int)CFG_LEN_MIN && c.magic == CFG_MAGIC) {
+			const int extOff = (int)offsetof(struct Cfg, ext);
+			if (got > extOff) {
+				int n = got - extOff;
+				if (n > (int)sizeof g_cfgExt)
+					n = sizeof g_cfgExt;
+				memcpy(g_cfgExt, c.ext, (size_t)n);
+			}
 			g_mDiv = c.mDiv ? c.mDiv : 64;
 			g_mFric = c.mFric;
 			for (int i = 0; i < ET_COUNT; i++)
@@ -209,8 +235,8 @@ void loadCfg()
 					if (c.padStick[i][k] <= PS_MAX)
 						g_padStickCfg[i][k] =
 							c.padStick[i][k];
-			// grow a short file to the current layout on the next save
-			if (got < (int)sizeof c)
+			// Missing extension bytes alone do not force a config rewrite.
+			if (got < (int)offsetof(struct Cfg, ext))
 				consume = true;
 
 			// lizard-suppression keepalive enable (0/1; anything else = a pre-0xCE cfg leaked
@@ -239,6 +265,7 @@ void loadCfg()
 			// suspend power-off enable (0xFF = a cfg.bin predating this tail field -> keep the on default)
 			if (c.suspendOff <= 1)
 				g_suspendOff = c.suspendOff;
+
 			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();

+ 4 - 0
OpenPuck/config.h

@@ -224,6 +224,10 @@ extern uint16_t g_loopPeriodUs;
 extern uint8_t g_loopWorst;
 extern uint16_t g_loopWorstUs;
 
+#define CFG_EXT_STORAGE_BYTES 10u
+uint8_t cfgExtRead(uint8_t index);
+void cfgExtWrite(uint8_t index, uint8_t value);
+
 void loadCfg();
 void saveCfg();
 // FULL factory wipe: reformat the internal LittleFS, erasing cfg.bin (modes/tunables/chords) AND bonds.bin

+ 13 - 3
OpenPuck/fw_update.cpp

@@ -2,6 +2,7 @@
 // meta-page commit arms it; the next boot applies it from RAM. See fw_update.h for the protocol and the
 // corruption-safety invariant every step here preserves.
 #include "fw_update.h"
+#include "rf_journal_layout.h"
 #include "fault_diag.h"
 #include <Arduino.h>
 #include <string.h>
@@ -19,9 +20,16 @@
 #define FWUP_FS_END 0xF4000UL
 #define FWUP_BL_SETTINGS 0xFF000UL
 #define FWUP_PAGE 4096UL
-// Image cap. Also what makes the apply copy safe from self-overlap: dst ends at most at 0x26000+0x60000 =
-// 0x86000, while the staged source starts at (0xEC000-size)&~0xFFF >= 0x8C000 -- disjoint by >=24 KiB.
-#define FWUP_MAX_IMG 0x60000UL
+// The normal application ends immediately before the fixed RF-journal window.
+// The largest staged image starts at 0x8C000, leaving 16 KiB between the
+// journal end (0x88000) and staging. This keeps both update source and target
+// disjoint from learned RF history.
+#define FWUP_MAX_IMG (RF_CHANNEL_JOURNAL_BASE - FWUP_APP_BASE)
+static_assert(FWUP_MAX_IMG == 0x60000UL,
+	      "RF journal layout changed the WebUSB application cap");
+static_assert(RF_CHANNEL_JOURNAL_END <=
+		      ((FWUP_META - FWUP_MAX_IMG) & ~(FWUP_PAGE - 1UL)),
+	      "RF journal overlaps staged WebUSB firmware storage");
 
 // Capability tag, searched for BY THE PANEL inside any .uf2 it is about to flash: an image without this
 // exact string predates panel updates, so flashing it silently locks future updates back to UF2-DFU
@@ -138,6 +146,8 @@ uint8_t fwupBegin(uint32_t size, uint32_t crc32)
 	if (size == 0 || size > FWUP_MAX_IMG || (size & 3))
 		return FWUP_ERR_BOUNDS;
 	uint32_t base = (FWUP_META - size) & ~(FWUP_PAGE - 1);
+	if (base < RF_CHANNEL_JOURNAL_END)
+		return FWUP_ERR_BOUNDS;
 	// keep a page of slack above the running image so staging can NEVER touch live code/rodata/.data-init
 	if (base < flashUsedEnd() + FWUP_PAGE)
 		return FWUP_ERR_BOUNDS;

+ 39 - 0
OpenPuck/haptics.cpp

@@ -690,6 +690,30 @@ void hapticOnReconnect(int slot)
 // hapticSteamRumble() path (so g_rumbleStyle / g_rumbleScale / the per-type rumble toggle all apply), and
 // schedule the stop. Deadline 0 = idle; millis()+MS can be 0 only once every 49 days, so bias it off zero.
 static unsigned long g_rumbleTestStop = 0;
+static unsigned long g_rfJournalBuilderTickStop = 0;
+static uint8_t g_rfJournalBuilderTickMask = 0;
+
+static const uint8_t RF_JOURNAL_BUILDER_TICK_ON[3] = { 0x01, 0x01, 0xF7 };
+static const uint8_t RF_JOURNAL_BUILDER_TICK_OFF[3] = { 0x01, 0x01, 0x00 };
+
+void hapticRfJournalBuilderTick(uint8_t participantMask)
+{
+	g_rfJournalBuilderTickMask = 0;
+	for (uint8_t s = 0; s < NSLOT; s++) {
+		const uint8_t bit = (uint8_t)(1u << s);
+		if ((participantMask & bit) && hapticLinkUp((int)s) &&
+		    relayEnqueue(0x82, RF_JOURNAL_BUILDER_TICK_ON,
+				 sizeof RF_JOURNAL_BUILDER_TICK_ON, true, s))
+			g_rfJournalBuilderTickMask |= bit;
+	}
+	if (!g_rfJournalBuilderTickMask) {
+		g_rfJournalBuilderTickStop = 0;
+		return;
+	}
+	g_rfJournalBuilderTickStop = millis() + RF_JOURNAL_BUILDER_TICK_MS;
+	if (!g_rfJournalBuilderTickStop)
+		g_rfJournalBuilderTickStop = 1;
+}
 
 void hapticTestRumble()
 {
@@ -709,6 +733,21 @@ void hapticTask()
 		for (uint8_t s = 0; s < NSLOT; s++)
 			hapticSteamRumble(0, 0, s);
 	}
+
+	// The journal-builder tick is outside the measured window. Queue explicit
+	// stops before the builder's inter-channel quiet guard expires.
+	if (g_rfJournalBuilderTickStop &&
+	    (long)(millis() - g_rfJournalBuilderTickStop) >= 0) {
+		g_rfJournalBuilderTickStop = 0;
+		const uint8_t mask = g_rfJournalBuilderTickMask;
+		g_rfJournalBuilderTickMask = 0;
+		for (uint8_t s = 0; s < NSLOT; s++)
+			if (mask & (uint8_t)(1u << s))
+				relayEnqueue(0x82, RF_JOURNAL_BUILDER_TICK_OFF,
+					     sizeof RF_JOURNAL_BUILDER_TICK_OFF,
+					     true, s);
+	}
+
 	// id9 steering (SET_SETTINGS index 9 = digital-mappings / the controller's AUTONOMOUS mapping+haptic
 	// engine, which is what generates the trackpad tick haptics). We decide per mode whether that autonomous
 	// engine should be ON, then either land id9=1 ONCE per connect episode (engine on) or hold id9=0 every

+ 6 - 0
OpenPuck/haptics.h

@@ -57,6 +57,12 @@ extern uint8_t g_rumbleStyle; // RUMBLE_STYLE_*
 #define RUMBLE_TEST_MS 500u
 void hapticTestRumble();
 
+// Journal-builder progress tick. It is deliberately short and is emitted only
+// between measured channels, never inside a quality window. Hardware testing
+// showed that this 0x82 haptic does not reset the controller inactivity timer.
+#define RF_JOURNAL_BUILDER_TICK_MS 150u
+void hapticRfJournalBuilderTick(uint8_t participantMask);
+
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // Enqueue one host->controller report. `slot` = bond slot (0..NSLOT-1) or 0xFF to broadcast to every
 // connected controller (used by hapticSendShutdown / hapticReinit / test haptics). ISR-safe (PRIMASK).

+ 4 - 2
OpenPuck/puck_hid.cpp

@@ -494,7 +494,8 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		// probe answered "still connected" and contradicted the disconnect we just pushed).
 		S.resp[2] = (slot >= 0 && slot < NSLOT && !g_xbox &&
 			     g_slot[slot].used && !slotPoweringOff(slot) &&
-			     (millis() - g_connReplyMs[slot] < 500)) ?
+			     ((millis() - g_connReplyMs[slot] < 500) ||
+			      rfChannelHandoffHostGrace(slot))) ?
 				    0x02 :
 				    0x01;
 		S.resp_len = 63;
@@ -983,7 +984,8 @@ void SteamPuckController::task()
 		// otherwise a stray dying reply bounces conn true -> a phantom 0x79=02 that Steam reads as a reconnect
 		// and answers by re-running its connect config (the "reappears for a split second").
 		bool conn = !slotPoweringOff(s) &&
-			    (millis() - g_connReplyMs[s] < 300);
+			    ((millis() - g_connReplyMs[s] < 300) ||
+			     rfChannelHandoffHostGrace(s));
 		// 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
 		// settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; an
 		// unconditional forever-resend re-triggers Steam's connect handling (connect chime) every 750ms before

+ 26 - 0
OpenPuck/radio.cpp

@@ -1,5 +1,6 @@
 #include "radio.h"
 #include "bonds.h" // g_slot[]
+#include "config.h"
 
 // Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key preceding "ibex" is a DIFFERENT key,
 // NOT the discovery address.
@@ -10,6 +11,7 @@ uint8_t g_rfBase[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
 // ch18: clean channel in the real puck's active hop set {18,2,80}; bad channels collide with trackpad
 // data bursts -> reply-rate crash. Tunable 'C'.
 uint8_t g_sessCh = 18;
+uint8_t g_rfStartupLastGoodChannel = 0;
 
 // Safe defaults; rfGenSessionAddr(slot) overwrites each used slot at boot. Discovery base "ibex" as a
 // degenerate starting point means an UN-bonded slot (and a not-yet-initialized one) won't accidentally
@@ -75,6 +77,30 @@ void rfGenSessionAddr(int slot)
 		g_sessBase[slot][0] ^= 0x80;
 }
 
+void rfApplyStartupLastGoodChannel()
+{
+	const uint8_t saved = cfgExtRead(0u);
+	g_rfStartupLastGoodChannel = (saved > 0u && saved <= 100u) ? saved : 0u;
+	cfgExtWrite(1u, 1u);
+	cfgExtWrite(2u, 1u);
+	if (g_rfStartupLastGoodChannel)
+		g_sessCh = g_rfStartupLastGoodChannel;
+}
+
+bool saveRfStartupLastGoodChannel(uint8_t channel)
+{
+	if (channel == 0u || channel > 100u)
+		return false;
+	if (g_rfStartupLastGoodChannel == channel)
+		return true;
+	g_rfStartupLastGoodChannel = channel;
+	cfgExtWrite(0u, channel);
+	cfgExtWrite(1u, 1u);
+	cfgExtWrite(2u, 1u);
+	saveCfg();
+	return true;
+}
+
 uint8_t rfrx[100], rftx[100];
 uint32_t g_rfRxCount = 0;
 

+ 3 - 0
OpenPuck/radio.h

@@ -29,6 +29,9 @@ extern uint8_t g_rfCh; // current TX/RX channel (hopped during beacon/poll)
 extern uint8_t g_rfBase[4]; // "ibex"
 // connected-session channel: a CLEAN data channel off the congested adv ch2 (shared by all slots on a puck)
 extern uint8_t g_sessCh;
+extern uint8_t g_rfStartupLastGoodChannel;
+void rfApplyStartupLastGoodChannel();
+bool saveRfStartupLastGoodChannel(uint8_t channel);
 
 // Per-bond SESSION address (base+prefix). Discovery stays on the SHARED "ibex" address (g_rfBase) so any
 // controller can find us; the host frame advertises THIS slot's unique address and the controller adopts it

+ 22 - 0
OpenPuck/rf_journal_layout.h

@@ -0,0 +1,22 @@
+// Fixed flash layout for the RF channel-history journal.
+//
+// Normal application images are capped immediately below this window, while
+// staged WebUSB update data starts above it. Keeping the journal outside both
+// regions lets learned RF history survive ordinary firmware updates. A full
+// board wipe still erases this application-flash window intentionally.
+#pragma once
+#include <stdint.h>
+
+#define RF_CHANNEL_JOURNAL_PAGE_BYTES 4096u
+#define RF_CHANNEL_JOURNAL_PAGE_COUNT 2u
+#ifndef OPK_RF_JOURNAL_BASE
+#define OPK_RF_JOURNAL_BASE 0x86000UL
+#endif
+#define RF_CHANNEL_JOURNAL_BASE OPK_RF_JOURNAL_BASE
+#define RF_CHANNEL_JOURNAL_END     \
+	(RF_CHANNEL_JOURNAL_BASE + \
+	 RF_CHANNEL_JOURNAL_PAGE_BYTES * RF_CHANNEL_JOURNAL_PAGE_COUNT)
+
+static_assert((RF_CHANNEL_JOURNAL_BASE &
+	       (RF_CHANNEL_JOURNAL_PAGE_BYTES - 1u)) == 0u,
+	      "RF journal base must be page aligned");

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3146 - 88
OpenPuck/rf_link.cpp


+ 99 - 0
OpenPuck/rf_link.h

@@ -115,6 +115,104 @@ extern volatile uint8_t g_battery[NSLOT];
 // charge state from report 0x43 body[0] (EChargeState: 1=discharging, 2=charging, 4=charging-done; 0=unknown)
 extern volatile uint8_t g_batteryState[NSLOT];
 
+#define RF_RECOVERY_STATUS_CHANNELS 14u
+
+enum RfChannelDesignation : uint8_t {
+	RF_CHANNEL_UNEXPLORED = 0,
+	RF_CHANNEL_GOOD = 1,
+	RF_CHANNEL_POOR = 2,
+	RF_CHANNEL_MIXED = 3,
+};
+
+struct RfChannelStatusEntry {
+	uint8_t channel;
+	uint8_t ambientRssi;
+	uint8_t designation;
+	uint8_t worstPct;
+	uint8_t meanPct;
+	uint8_t confidence;
+	uint8_t trials;
+	uint8_t penalty;
+	uint8_t recentOrder;
+};
+
+enum RfRecoveryHandoffPhase : uint8_t {
+	RF_RECOVERY_HANDOFF_IDLE = 0,
+	RF_RECOVERY_HANDOFF_WAIT_NEUTRAL = 1,
+	RF_RECOVERY_HANDOFF_AUTHORIZE = 2,
+	RF_RECOVERY_HANDOFF_SWITCH = 3,
+	RF_RECOVERY_HANDOFF_ACQUIRE_TARGET = 4,
+	RF_RECOVERY_HANDOFF_RECONCILE = 5,
+	RF_RECOVERY_HANDOFF_ROLLBACK_SWITCH = 6,
+	RF_RECOVERY_HANDOFF_ACQUIRE_OLD = 7,
+};
+
+enum RfRecoveryWaitReason : uint8_t {
+	RF_RECOVERY_WAIT_NONE = 0,
+	RF_RECOVERY_WAIT_FRESH_REPORT = 1,
+	RF_RECOVERY_WAIT_INPUT_ACTIVE = 2,
+	RF_RECOVERY_WAIT_NEUTRAL_DWELL = 3,
+};
+
+enum RfAmbientSurveyFailure : uint8_t {
+	RF_AMBIENT_SURVEY_FAIL_NONE = 0,
+	RF_AMBIENT_SURVEY_FAIL_SAMPLE_TIMEOUT = 1,
+	RF_AMBIENT_SURVEY_FAIL_INCOMPLETE = 2,
+};
+
+enum RfJournalBuilderPhase : uint8_t {
+	RF_JOURNAL_BUILDER_IDLE = 0,
+	RF_JOURNAL_BUILDER_SURVEY = 1,
+	RF_JOURNAL_BUILDER_HOPPING = 2,
+	RF_JOURNAL_BUILDER_SETTLING = 3,
+	RF_JOURNAL_BUILDER_MEASURING = 4,
+	RF_JOURNAL_BUILDER_BETWEEN = 5,
+	RF_JOURNAL_BUILDER_SELECTING = 6,
+	RF_JOURNAL_BUILDER_FINAL_HOP = 7,
+	RF_JOURNAL_BUILDER_SAVING = 8,
+	RF_JOURNAL_BUILDER_PAUSED = 9,
+	RF_JOURNAL_BUILDER_COMPLETE = 10,
+	RF_JOURNAL_BUILDER_CANCELED = 11,
+	RF_JOURNAL_BUILDER_FAILED = 12,
+};
+
+struct RfRecoveryStatus {
+	uint8_t version;
+	uint8_t flags;
+	uint8_t currentChannel;
+	uint8_t targetChannel;
+	uint8_t startupChannel;
+	uint8_t channelCount;
+	uint8_t journalWrites;
+	uint16_t ambientGeneration;
+	uint8_t ambientSurveyChannel;
+	uint32_t journalSequence;
+	RfChannelStatusEntry channel[RF_RECOVERY_STATUS_CHANNELS];
+	uint8_t handoffPhase;
+	uint64_t handoffElapsedMs;
+	uint8_t handoffOldChannel;
+	uint8_t journalBuilderPhase;
+	uint8_t journalBuilderIndex;
+	uint8_t journalBuilderChannel;
+	uint8_t journalBuilderProgress;
+	uint8_t journalBuilderParticipantMask;
+	uint8_t journalBuilderBestChannel;
+	uint8_t journalBuilderFailure;
+	uint8_t handoffWaitReason;
+	uint16_t handoffNeutralMs;
+	uint8_t recoveryCooldownSeconds;
+	uint8_t recoveryFailedTarget;
+	uint8_t ambientSurveyRetry;
+	uint8_t ambientSurveyFailure;
+	uint8_t ambientSurveyFailureChannel;
+};
+
+bool rfRecoveryRequestAmbientSurvey();
+bool rfRecoveryRequestHop(uint8_t channel);
+bool rfRecoveryRequestJournalBuilder();
+void rfRecoveryCancelJournalBuilder();
+void rfRecoveryStatusSnapshot(RfRecoveryStatus *status);
+
 // TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
 // rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect
 // no reply, so they don't burn a full ~1.2ms window of dead air per haptic.
@@ -122,5 +220,6 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 		 uint16_t rxWinUs = 0);
 // Mid-session channel hop: advertise newCh on the current channel a few times, then move the poll to newCh.
 void rfHopTo(uint8_t newCh);
+bool rfChannelHandoffHostGrace(int slot);
 // Per-loop: host-frame beacons + connected-mode poll + remote-wakeup + QoS hop + per-second stats.
 void rfLinkTask();

+ 2 - 3
OpenPuck/serial_console.cpp

@@ -426,9 +426,8 @@ void serialConsolePoll()
 				g_hopIdx = 0;
 				g_qosBad = 0;
 				g_qosCheckMs = millis();
-				Serial.printf(
-					"# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",
-					g_qos ? "ON" : "off");
+				Serial.printf("# adaptive RF recovery %s\n",
+					      g_qos ? "ON" : "off");
 			} else if (line[0] == 'U') {
 				// Per-slot debug dump: which controllers are connected, their RSSI, haptic-block
 				// state, and session address. Quick at-a-glance view for multi-controller testing.

+ 136 - 5
OpenPuck/webusb_config.cpp

@@ -3,6 +3,7 @@
 #include "config.h"
 #include "bonds.h"
 #include "rf_link.h"
+#include "radio.h"
 #include "haptics.h"
 #include "puck_hid.h"
 #include "triton.h" // g_in raw IMU (diagnostic readout)
@@ -94,13 +95,14 @@ static bool boardCommand(uint8_t op)
 //                [v12: relayps(lo,hi)][clkLfSrc][clkHfSrc][usPerMs(lo,hi)][hangStage][curStage][stallMs/40][ringFault(lo,hi)][hangPC u32][hangLR u32][usbdStackFree(lo,hi)][loopStackFree(lo,hi)]
 //                [v13: per-slot link stats, 4x9B from p[145]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
 //                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
-//                [v14/v17: p[181] landAll87 (verbatim-0x87-relay experiment toggle)]
+//                [v14/v17: p[181] legacy relay field, now Steam Machine emulation toggle (0xEE=on)]
 //                [v18: p[182..185] chordDpad left/up/right/down (back4+D-pad mode assignments)]
 //                [v19: p[186] swGyroLegacy (Switch Pro gyro mapping: 0 = corrected, 1 = legacy/pre-#189)]
 //                [v20: p[187..194] per-type trackpad->stick map, 4x2B {left pad, right pad} (PS_OFF/LEFT/RIGHT)]
 //                [v21: p[53] rumble strength as PERCENT/2 (field 22, revived); p[195] rumble style
 //                 (field 39, RUMBLE_STYLE_* in haptics.h)]
-#define WB_PAYLEN 194
+//                [v22: p[196] RF recovery status/journal transport capability marker (0x52)]
+#define WB_PAYLEN 195
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -129,6 +131,7 @@ static void webusbSendBlob()
 	// protocol version
 	// (21 = +rumble style (field 39, blob p[195]) and the REVIVED rumble-strength field 22 at blob p[53], 
 	// now carrying percent/2; 
+	// 22 = +RF recovery capability marker at blob p[196];
 	// 20 = +per-type trackpad->stick mapping (fields 80..87, blob p[187..194]); 
 	// 19 = +Switch Pro legacy-gyro select (field 38, blob p[186]); the Switch report-rate and
 	// gyro-scale settings (fields 23/24, blob p[54..55]) are GONE -- those bytes read 0; 
@@ -148,7 +151,7 @@ static void webusbSendBlob()
 	// 7 = +raw accel; 
 	// 6 = +swPro120/gyroScale)
 	// clang-format on
-	p[2] = 21;
+	p[2] = 22;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -314,7 +317,8 @@ static void webusbSendBlob()
 		q[7] = g_slotNoRxps[s];
 		q[8] = g_slotRelayps[s];
 	}
-	// v14/v17: verbatim-0x87-relay (g_landAll87), later repurposed for Machine toggle
+	// v14/v17: verbatim-0x87-relay field, later repurposed for the Steam Machine toggle.
+	// Keep the current upstream meaning here; RF capability is append-only at p[196].
 	p[181] = (uint8_t)(g_isMachineInternal ? 0xEE : 0);
 	// v18: back4+D-pad mode assignments (panel renders these as selects next to the B/X/Y ones)
 	p[182] = g_chordDpad[CHD_LEFT];
@@ -330,6 +334,9 @@ static void webusbSendBlob()
 	}
 	// v21: host-rumble style (RUMBLE_STYLE_* -- see haptics.h)
 	p[195] = g_rumbleStyle;
+	// v22: append-only RF recovery transport capability. p[181] remains the
+	// upstream Steam Machine toggle; older RF builds used that byte for 0x52.
+	p[196] = 0x52;
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -484,6 +491,82 @@ static void webusbDrainFlight(bool restart)
 
 // Runs on the usbd task (registered via usbTxRegisterDrain -> tud_sof_cb). Sends the blob if loop() asked for
 // one. Keeps every usb_web write/flush off the loop task so it can't block on the device event queue.
+static volatile bool g_rfStatusRequest = false;
+
+static bool webusbSendRfStatus()
+{
+	if (!usb_web.connected())
+		return false;
+	static RfRecoveryStatus status;
+	rfRecoveryStatusSnapshot(&status);
+	// Append-only trailers after the counted channel rows preserve the v1
+	// header/row offsets: 4 bytes legacy handoff telemetry + 7 bytes journal-
+	// builder status + 1 byte ambient-survey progress + 5 bytes automatic-
+	// handoff admission/retry diagnostics + 3 bytes ambient-survey retry/failure
+	// diagnostics + 8 bytes full-width handoff elapsed time.
+	static uint8_t f[2 + 13 + RF_RECOVERY_STATUS_CHANNELS * 9 + 4 + 7 + 1 +
+			 5 + 3 + 8];
+	uint8_t *q = f + 2;
+	f[0] = 0xAD;
+	f[1] = (uint8_t)(sizeof f - 2u);
+	*q++ = status.version;
+	*q++ = status.flags;
+	*q++ = status.currentChannel;
+	*q++ = status.targetChannel;
+	*q++ = status.startupChannel;
+	*q++ = status.channelCount;
+	*q++ = status.journalWrites;
+	*q++ = (uint8_t)status.ambientGeneration;
+	*q++ = (uint8_t)(status.ambientGeneration >> 8);
+	*q++ = (uint8_t)status.journalSequence;
+	*q++ = (uint8_t)(status.journalSequence >> 8);
+	*q++ = (uint8_t)(status.journalSequence >> 16);
+	*q++ = (uint8_t)(status.journalSequence >> 24);
+	for (uint8_t i = 0; i < status.channelCount; i++) {
+		const RfChannelStatusEntry &entry = status.channel[i];
+		*q++ = entry.channel;
+		*q++ = entry.ambientRssi;
+		*q++ = entry.designation;
+		*q++ = entry.worstPct;
+		*q++ = entry.meanPct;
+		*q++ = entry.confidence;
+		*q++ = entry.trials;
+		*q++ = entry.penalty;
+		*q++ = entry.recentOrder;
+	}
+	*q++ = status.handoffPhase;
+	const uint16_t legacyHandoffElapsedMs =
+		status.handoffElapsedMs > 0xFFFFu ?
+			0xFFFFu :
+			(uint16_t)status.handoffElapsedMs;
+	*q++ = (uint8_t)legacyHandoffElapsedMs;
+	*q++ = (uint8_t)(legacyHandoffElapsedMs >> 8);
+	*q++ = status.handoffOldChannel;
+	*q++ = status.journalBuilderPhase;
+	*q++ = status.journalBuilderIndex;
+	*q++ = status.journalBuilderChannel;
+	*q++ = status.journalBuilderProgress;
+	*q++ = status.journalBuilderParticipantMask;
+	*q++ = status.journalBuilderBestChannel;
+	*q++ = status.journalBuilderFailure;
+	*q++ = status.ambientSurveyChannel;
+	*q++ = status.handoffWaitReason;
+	*q++ = (uint8_t)status.handoffNeutralMs;
+	*q++ = (uint8_t)(status.handoffNeutralMs >> 8);
+	*q++ = status.recoveryCooldownSeconds;
+	*q++ = status.recoveryFailedTarget;
+	*q++ = status.ambientSurveyRetry;
+	*q++ = status.ambientSurveyFailure;
+	*q++ = status.ambientSurveyFailureChannel;
+	for (uint8_t shift = 0; shift < 64u; shift += 8u)
+		*q++ = (uint8_t)(status.handoffElapsedMs >> shift);
+	if (tud_vendor_write_available() < sizeof f)
+		return false;
+	usb_web.write(f, sizeof f);
+	usb_web.flush();
+	return true;
+}
+
 static void webusbSofDrain(void)
 {
 	// If loop() has stopped beating, it's wedged -- keep pushing the blob (which carries the live stuck stage)
@@ -513,6 +596,8 @@ static void webusbSofDrain(void)
 		g_bondExportRequest = false;
 		webusbSendBondExport();
 	}
+	if (g_rfStatusRequest && webusbSendRfStatus())
+		g_rfStatusRequest = false;
 }
 // ---- live wedge reporter (0xA9) --------------------------------------------------------------------------
 // THE one channel that survives a loop() wedge on boards that wipe .noinit/GPREGRET across the watchdog reset
@@ -916,6 +1001,10 @@ void webusbPoll()
 
 				// every settable field persists (poll rate is no longer settable)
 				bool persist = true;
+				// RF controls (97..101) use the dedicated 0xAD reply only. Scheduling
+				// the generic 0xA5 blob as well lets the SOF drain expose either frame
+				// first and phase-shifts the browser's shared bulk-IN stream.
+				const bool rfStatusOnly = f >= 97u && f <= 101u;
 				// per-type cfg writes (protocol v10/v17): field = 40 + et*9 + k, k: 0..3 back, 4 qam, 5 abSwap,
 				// 6 padHaptics, 7 ledBright, 8 rumble. Edits g_type[et]; refresh the live mirrors if it's the active type.
 				if (f >= 40 && f < 40 + ET_COUNT * 9) {
@@ -1099,6 +1188,47 @@ void webusbPoll()
 					// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
 					// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
 
+				// RF recovery controls reuse the field setter without its generic
+				// persistence path. Field 100 persists only the selected startup channel.
+				case 97:
+					g_rfStatusRequest = true;
+					persist = false;
+					break;
+				case 98:
+					(void)rfRecoveryRequestAmbientSurvey();
+					g_rfStatusRequest = true;
+					persist = false;
+					break;
+				case 99:
+					(void)rfRecoveryRequestHop(v);
+					g_rfStatusRequest = true;
+					persist = false;
+					break;
+				case 101:
+					if (v)
+						(void)rfRecoveryRequestJournalBuilder();
+					else
+						rfRecoveryCancelJournalBuilder();
+					g_rfStatusRequest = true;
+					persist = false;
+					break;
+				case 100: {
+					static RfRecoveryStatus status;
+					rfRecoveryStatusSnapshot(&status);
+					for (uint8_t i = 0;
+					     i < status.channelCount; i++) {
+						if (status.channel[i].channel !=
+						    v)
+							continue;
+						(void)saveRfStartupLastGoodChannel(
+							v);
+						break;
+					}
+					g_rfStatusRequest = true;
+					persist = false;
+					break;
+				}
+
 				// Used to be g_landAll87, now g_isMachineInternal. Persisted to blob p[181].
 				case 29:
 					g_isMachineInternal = v ? 0xEE : 0;
@@ -1106,7 +1236,8 @@ void webusbPoll()
 				}
 				if (persist)
 					saveCfg();
-				g_blobRequest = true;
+				if (!rfStatusOnly)
+					g_blobRequest = true;
 			} else if (op == 0x03) {
 				uint8_t m = buf[1];
 				if (modeValid(m) && !USBDevice.suspended()) {

+ 416 - 2
docs/index.html

@@ -263,10 +263,36 @@
       </div>
       <div class="row debugonly" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
         <span class="v" id="stabStatus" style="flex:0 0 auto;min-width:120px">—</span>
-        <span class="note" style="flex:1">Buzzes the controllers every 10 s (keeps them awake) and times how long the puck stays up. On a reset it records the uptime and shows it by <em>Last reset</em>; it auto-resumes after the puck reconnects. The count is kept in this tab (a page refresh clears it).</span>
+        <span class="note" style="flex:1">Buzzes the controllers every 10 s and times how long the puck stays up. On a reset it records the uptime and shows it by <em>Last reset</em>; it auto-resumes after the puck reconnects. The count is kept in this tab (a page refresh clears it).</span>
       </div>
     </div>
 
+    <div class="card wide hide" id="rfRecoveryCard">
+      <h2>RF recovery &amp; channel journal</h2>
+      <div class="statgrid">
+        <div class="stat"><div class="k">Active channel</div><div class="v" id="rfActiveCh">—</div></div>
+        <div class="stat"><div class="k">Recovery target</div><div class="v" id="rfTargetCh">—</div></div>
+        <div class="stat"><div class="k">Startup channel</div><div class="v" id="rfStartupCh">—</div></div>
+        <div class="stat"><div class="k">Ambient survey</div><div class="v" id="rfSurveyState">—</div></div>
+        <div class="stat"><div class="k">Journal sequence</div><div class="v" id="rfJournalSeq">—</div></div>
+        <div class="stat"><div class="k">Journal writes this boot</div><div class="v" id="rfJournalWrites">—</div></div>
+        <div class="stat"><div class="k">Handoff status</div><div class="v" id="rfHandoffStatus" style="font-size:14px">Idle</div></div>
+        <div class="stat"><div class="k">Journal builder</div><div class="v" id="rfBuilderStatus" style="font-size:14px">Idle</div></div>
+      </div>
+      <div class="row" style="margin-top:10px">
+        <button id="rfSurvey">Survey Ambient RF</button>
+        <button id="rfBuilder" style="margin-left:8px">Build RF Journal</button>
+      </div>
+      <p class="note" style="margin-top:10px"><strong>Survey Ambient RF</strong> samples background RF interference across all 14 recovery channels and updates the Ambient measurements shown below.<br><strong>Build RF Journal</strong> will initiate a comprehensive 14-channel scan to build the RF Journal ahead of time and save the winning channel as Startup.<br><strong>Hop</strong> uses the same neutral-gated coordinated channel migration as automatic RF recovery.<br><strong>Startup</strong> manually saves that channel as the starting channel for subsequent boots, but automatic RF learning may replace it later.</p>
+      <div style="overflow-x:auto;margin-top:12px">
+        <table style="width:100%;border-collapse:collapse;font-size:0.88em">
+          <thead><tr style="text-align:left"><th>Channel</th><th>MHz</th><th>Journal</th><th>Worst</th><th>Mean</th><th>Confidence</th><th>Trials</th><th>Penalty</th><th>Ambient</th><th>Recent</th><th>Actions</th></tr></thead>
+          <tbody id="rfJournalBody"></tbody>
+        </table>
+      </div>
+      <div id="rfAmbientBars" style="display:flex;gap:4px;align-items:flex-end;height:76px;margin-top:12px;padding:8px;background:#151821;border:1px solid #272d3d;border-radius:6px"></div>
+    </div>
+
     <div class="card" id="mouseCard">
       <h2>Trackpad mouse (lizard + Xbox)</h2>
       <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
@@ -738,6 +764,386 @@ const RUMBLE_SCALES=[50,75,100,150,200,250,300,400,500];
     const o=document.createElement("option"); o.value=v; o.textContent=v+"%"+(v===200?" (default)":""); sel.appendChild(o); }
 }
 
+// ---- RF recovery / journal status ----
+let rfCapable=false, rfBusy=false, rfLastRefresh=0, rfHopPending=false, rfHandoffActive=false, rfHandoffPhase=0, rfBuilderActive=false, rfBuilderSaving=false, rfBuilderStartPending=false, rfSurveyRunning=false, rfSurveyPending=false, rfSurveyGeneration=0, rfSurveyWatchActive=false, rfSurveyLockActive=false, rfSurveyLockGeneration=0, rfSurveyRetry=0, rfSurveyFailure=0, rfSurveyFailureChannel=0;
+let rfHopRequestedCh=0, rfHopSawHandoff=false;
+let rfPoolChannels=new Set();
+const RF_DESIG=["unexplored","good","poor","mixed"];
+function rfChLabel(ch){ return ch ? `Ch ${ch} · ${2400+ch} MHz` : "—"; }
+function rfElapsedLabel(ms){
+  if(ms<1000) return `${ms} ms`;
+  const s=Math.floor(ms/1000);
+  if(s<60) return `${(ms/1000).toFixed(1)} s`;
+  const m=Math.floor(s/60);
+  if(m<60) return `${m}m ${String(s%60).padStart(2,"0")}s`;
+  const h=Math.floor(m/60);
+  if(h<24) return `${h}h ${String(m%60).padStart(2,"0")}m`;
+  const d=Math.floor(h/24);
+  return `${d}d ${String(h%24).padStart(2,"0")}h`;
+}
+function applyRfStatus(p){
+  if(!p || p.length<13 || p[0]!==1) return;
+  const flags=p[1], current=p[2], target=p[3], startup=p[4], count=p[5];
+  const writes=p[6], generation=p[7]|(p[8]<<8);
+  const seq=((p[9]|(p[10]<<8)|(p[11]<<16)|(p[12]<<24))>>>0);
+  $("#rfActiveCh").textContent=rfChLabel(current);
+  $("#rfTargetCh").textContent=target?rfChLabel(target):"none";
+  $("#rfStartupCh").textContent=rfChLabel(startup || 18);
+  $("#rfJournalSeq").textContent=String(seq);
+  $("#rfJournalWrites").textContent=String(writes);
+  const running=!!(flags&1), pending=!!(flags&2), ambient=!!(flags&4), live=!!(flags&8), handoff=!!(flags&16);
+  const tail=13+count*9;
+  const hopPhase=p.length>=tail+4?p[tail]:0;
+  const hopElapsed16=p.length>=tail+4?(p[tail+1]|(p[tail+2]<<8)):0;
+  const hopOld=p.length>=tail+4?p[tail+3]:0;
+  // The automatic-handoff trailer follows 4 handoff bytes, 7 Builder bytes,
+  // and the ambient-survey channel byte. Parse it before rendering handoff state.
+  const autoBase=tail+12;
+  const waitReason=p.length>=autoBase+5?p[autoBase]:0;
+  const neutralMs=p.length>=autoBase+5?(p[autoBase+1]|(p[autoBase+2]<<8)):0;
+  const recoveryCooldown=p.length>=autoBase+5?p[autoBase+3]:0;
+  const recoveryFailedTarget=p.length>=autoBase+5?p[autoBase+4]:0;
+  const surveyDiagBase=autoBase+5;
+  const surveyRetry=p.length>=surveyDiagBase+3?p[surveyDiagBase]:0;
+  const surveyFailure=p.length>=surveyDiagBase+3?p[surveyDiagBase+1]:0;
+  const surveyFailureChannel=p.length>=surveyDiagBase+3?p[surveyDiagBase+2]:0;
+  const handoffElapsedBase=surveyDiagBase+3;
+  let hopElapsed=hopElapsed16;
+  if(p.length>=handoffElapsedBase+8){
+    const lo=((p[handoffElapsedBase]|(p[handoffElapsedBase+1]<<8)|(p[handoffElapsedBase+2]<<16)|(p[handoffElapsedBase+3]<<24))>>>0);
+    const hi=((p[handoffElapsedBase+4]|(p[handoffElapsedBase+5]<<8)|(p[handoffElapsedBase+6]<<16)|(p[handoffElapsedBase+7]<<24))>>>0);
+    hopElapsed=hi*4294967296+lo;
+  }
+  const hopPhaseText=["Idle","Waiting for neutral","Authorizing","Switching","Acquiring","Reconciling","Rolling back","Reacquiring"];
+  let hopText=hopPhaseText[hopPhase]||("Phase "+hopPhase);
+  if(hopPhase===1){
+    if(waitReason===1) hopText="Starting hop · awaiting fresh controller reports";
+    else if(waitReason===2) hopText="Input detected · waiting for neutral";
+    else if(waitReason===3) hopText=`Neutral · ${Math.min(neutralMs,250)}/250 ms`;
+  }
+  if(hopPhase===2||hopPhase===3||hopPhase===4) hopText+=(target?` Ch ${target}`:"");
+  else if(hopPhase>=5) hopText+=(hopOld?` Ch ${hopOld}`:"");
+  if(hopPhase) hopText+=` · ${rfElapsedLabel(hopElapsed)}`;
+  else if(recoveryCooldown) hopText=`Recovery cooldown · ${recoveryCooldown}s${recoveryFailedTarget?` · Ch ${recoveryFailedTarget} excluded`:""}`;
+  if(hopPhase||!rfHopPending) $("#rfHandoffStatus").textContent=hopText;
+  const builderBase=tail+4;
+  const builderPhase=p.length>=builderBase+7?p[builderBase]:0;
+  const builderIndex=p.length>=builderBase+7?p[builderBase+1]:0;
+  const builderChannel=p.length>=builderBase+7?p[builderBase+2]:0;
+  const builderProgress=p.length>=builderBase+7?p[builderBase+3]:0;
+  const builderMask=p.length>=builderBase+7?p[builderBase+4]:0;
+  const builderBest=p.length>=builderBase+7?p[builderBase+5]:0;
+  const builderFailure=p.length>=builderBase+7?p[builderBase+6]:0;
+  const surveyChannel=p.length>=builderBase+8?p[builderBase+7]:0;
+  const builderFailureText=["","no live controller","RF workflow busy","journal storage full","no valid channel","final hop failed","journal save failed","journal write could not be completed","could not read controller idle timeout","startup save failed","ambient survey failed"];
+  let builderText="Idle";
+  if(builderPhase===1) builderText="Preparing controllers / ambient survey";
+  else if(builderPhase===2) builderText=`Testing ${Math.min(builderIndex+1,count)}/${count} · hopping to Ch ${builderChannel}`;
+  else if(builderPhase===3) builderText=`Testing ${Math.min(builderIndex+1,count)}/${count} · settling Ch ${builderChannel}`;
+  else if(builderPhase===4) builderText=`Testing ${Math.min(builderIndex+1,count)}/${count} · Ch ${builderChannel} · ${builderProgress}/60 s`;
+  else if(builderPhase===5) builderText=`Testing ${Math.min(builderIndex+1,count)}/${count} · between channels`;
+  else if(builderPhase===6) builderText="Selecting best channel";
+  else if(builderPhase===7) builderText=`Final hop · Ch ${builderBest||builderChannel}`;
+  else if(builderPhase===8) builderText=`Saving journal${builderBest?` · Ch ${builderBest}`:""}`;
+  else if(builderPhase===9) builderText=`Paused · waiting for controller cohort 0x${builderMask.toString(16).toUpperCase()}`;
+  else if(builderPhase===10) builderText=`Complete${builderBest?` · Ch ${builderBest} selected`:""}`;
+  else if(builderPhase===11) builderText="Canceled";
+  else if(builderPhase===12) builderText=`Failed · ${builderFailureText[builderFailure]||("code "+builderFailure)}`;
+  $("#rfBuilderStatus").textContent=builderText;
+  rfBuilderActive=builderPhase>=1&&builderPhase<=9;
+  rfBuilderSaving=builderPhase===8;
+  rfHandoffActive=handoff;
+  rfHandoffPhase=hopPhase;
+  if(rfBuilderStartPending&&builderPhase!==0) rfBuilderStartPending=false;
+  $("#rfBuilder").textContent=rfBuilderActive?"Cancel Journal Build":rfBuilderStartPending?"Starting…":"Build RF Journal";
+  syncRfBuilderDisabled();
+  rfSurveyRunning=running;
+  rfSurveyPending=pending;
+  rfSurveyGeneration=generation;
+  rfSurveyRetry=surveyRetry;
+  rfSurveyFailure=surveyFailure;
+  rfSurveyFailureChannel=surveyFailureChannel;
+  if(rfSurveyLockActive&&(surveyFailure||(!running&&!pending&&generation!==rfSurveyLockGeneration)))
+    rfSurveyLockActive=false;
+  if(rfHopPending&&handoff) rfHopSawHandoff=true;
+  if(rfHopPending&&!rfHopRequestedCh&&!handoff) rfHopPending=false;
+  const surveyFailureText=["","sample timeout","incomplete scan"];
+  $("#rfSurveyState").textContent=running?(surveyRetry&&surveyChannel?`retrying Ch ${surveyChannel} · ${surveyRetry}/5`:surveyChannel?`surveying Ch ${surveyChannel}`:"surveying"):pending?(handoff?"queued for handoff":"starting survey"):surveyFailure?`failed${surveyFailureChannel?` · Ch ${surveyFailureChannel}`:""} · ${surveyFailureText[surveyFailure]||(`code ${surveyFailure}`)}`:ambient?`cached #${generation}`:"not sampled";
+  const body=$("#rfJournalBody"), bars=$("#rfAmbientBars"); body.innerHTML=""; bars.innerHTML=""; rfPoolChannels=new Set();
+  for(let i=0;i<count;i++){
+    const q=13+i*9; if(q+9>p.length) break;
+    const ch=p[q], noise=p[q+1], d=p[q+2], worst=p[q+3], mean=p[q+4], conf=p[q+5], trials=p[q+6], penalty=p[q+7], recent=p[q+8];
+    rfPoolChannels.add(ch);
+    const tr=document.createElement("tr"); tr.dataset.rfCh=String(ch);
+    const desig=RF_DESIG[d]||("state "+d);
+    tr.innerHTML=`<td>${ch}${ch===current?' <span class="pill up">active</span>':''}${ch===target?' <span class="pill rf-target">target</span>':''}</td><td>${2400+ch}</td><td>${desig}</td><td>${trials?worst+'%':'—'}</td><td>${trials?mean+'%':'—'}</td><td>${trials?conf:'—'}</td><td>${trials}</td><td>${trials?penalty:'—'}</td><td>${noise?'-'+noise+' dBm':'—'}</td><td>${recent||'—'}</td>`;
+    const hopCell=document.createElement("td");
+    const hopBtn=document.createElement("button");
+    hopBtn.textContent=ch===current?"Active":"Hop";
+    hopBtn.dataset.rfHop="1";
+    hopBtn.disabled=ch===current||rfSurveyLockActive||rfHopWorkflowBlocked()||rfHopPending||rfBuilderActive;
+    hopBtn.style.padding="4px 8px";
+    hopBtn.dataset.rfCurrent=ch===current?"1":"0";
+    hopBtn.addEventListener("click",()=>hopRf(ch));
+    hopCell.appendChild(hopBtn);
+    const startupBtn=document.createElement("button");
+    startupBtn.textContent=ch===startup?"Startup ✓":"Startup";
+    startupBtn.dataset.rfStartup="1";
+    startupBtn.dataset.rfStartupSelected=ch===startup?"1":"0";
+    startupBtn.disabled=rfSurveyLockActive||ch===startup;
+    startupBtn.style.cssText="padding:4px 8px;margin-left:6px";
+    startupBtn.title="Use this channel on the next boot. This does not mark the channel good.";
+    startupBtn.addEventListener("click",()=>manualStartupRf(ch));
+    hopCell.appendChild(startupBtn);
+    tr.appendChild(hopCell);
+    body.appendChild(tr);
+    const col=document.createElement("div"); col.style.cssText="flex:1;min-width:18px;height:100%;display:flex;flex-direction:column;justify-content:flex-end;align-items:center";
+    const h=noise?Math.max(6,Math.min(58,(noise-35)*1.15)):4;
+    col.title=noise?`Ch ${ch}: ambient peak -${noise} dBm`:`Ch ${ch}: no ambient sample`;
+    col.innerHTML=`<div style="font-size:9px;color:#8ba2c4">${noise?'-'+noise:''}</div><div style="width:100%;height:${h}px;background:currentColor;opacity:${noise?'.72':'.18'};border-radius:2px 2px 0 0"></div><div style="font-size:9px;margin-top:2px">${ch}</div>`;
+    bars.appendChild(col);
+  }
+  syncRfSurveyControls();
+}
+function syncRfSurveyControls(){
+  const locked=rfSurveyLockActive;
+  $("#rfSurvey").disabled=locked||rfBuilderActive;
+  for(const tr of $("#rfJournalBody").querySelectorAll("tr")){
+    for(const btn of tr.querySelectorAll("button[data-rf-hop]"))
+      btn.disabled=locked||rfHopPending||rfBuilderActive||btn.dataset.rfCurrent==="1"||rfHopWorkflowBlocked();
+    for(const btn of tr.querySelectorAll("button[data-rf-startup]"))
+      btn.disabled=locked||btn.dataset.rfStartupSelected==="1";
+  }
+  syncRfBuilderDisabled();
+}
+function rfBuilderWorkflowBlocked(){
+  // Waiting-for-neutral is the automatic, pre-E4 admission phase. Firmware can
+  // safely yield that phase to an explicit Builder request; every other live
+  // handoff phase remains non-preemptible.
+  return (rfHandoffActive||rfHopPending) && !(rfHandoffActive&&rfHandoffPhase===1);
+}
+function rfHopWorkflowBlocked(){
+  // Hop itself now uses the production automatic neutral-gated path, so an
+  // existing handoff remains authoritative until it reaches a terminal state.
+  return rfHandoffActive;
+}
+function syncRfBuilderDisabled(){
+  $("#rfBuilder").disabled=rfSurveyLockActive||rfBuilderSaving||rfBuilderStartPending||(!rfBuilderActive&&rfBuilderWorkflowBlocked());
+}
+function setRfHopButtonsDisabled(disabled){
+  rfHopPending=!!disabled;
+  for(const tr of $("#rfJournalBody").querySelectorAll("tr")){
+    for(const btn of tr.querySelectorAll("button[data-rf-hop]"))
+      btn.disabled=rfSurveyLockActive||rfHopPending||rfBuilderActive||btn.dataset.rfCurrent==="1"||rfHopWorkflowBlocked();
+  }
+  // Keep the Builder interlock synchronized with the local Hop latch as well
+  // as firmware handoff state. The terminal status frame arrives before the
+  // Hop finally block releases rfHopPending, so applyRfStatus() may have
+  // left this DOM button disabled using the old latch value.
+  syncRfBuilderDisabled();
+}
+function showRfHopTarget(ch){
+  $("#rfTargetCh").textContent=rfChLabel(ch);
+  for(const tr of $("#rfJournalBody").querySelectorAll("tr")){
+    const cell=tr.cells[0]; if(!cell) continue;
+    for(const badge of cell.querySelectorAll(".rf-target")) badge.remove();
+    if(+tr.dataset.rfCh===ch){
+      const badge=document.createElement("span"); badge.className="pill rf-target"; badge.textContent="target";
+      cell.append(" ",badge);
+    }
+  }
+}
+async function waitRfUiIdle(){
+  for(let i=0;i<100&&rfBusy&&dev;i++) await new Promise(resolve=>setTimeout(resolve,50));
+  return !!dev&&!rfBusy;
+}
+async function hopRf(ch){
+  if(!dev||isDongle||!rfCapable||!rfPoolChannels.has(ch)||rfSurveyLockActive||rfHopPending||rfBuilderActive||rfHopWorkflowBlocked()) return;
+  setRfHopButtonsDisabled(true);
+  rfHopRequestedCh=ch;
+  rfHopSawHandoff=false;
+  showRfHopTarget(ch);
+  $("#rfHandoffStatus").textContent=`Requesting Ch ${ch}`;
+  if(!(await waitRfUiIdle())){
+    $("#rfHandoffStatus").textContent=`Hop not started · Ch ${ch}`;
+    rfHopRequestedCh=0;
+    setRfHopButtonsDisabled(false);
+    return;
+  }
+  rfBusy=true;
+  let terminal=false, sawStatus=false;
+  try{
+    await waitIdle(); if(!dev) return;
+    await send([0x02,99,ch]);
+    let p=await readFrame(0xAD,13,256);
+    for(let i=0;i<1200&&dev;i++){
+      if(p){
+        sawStatus=true;
+        applyRfStatus(p);
+        const active=!!(p[1]&16), current=p[2];
+        if(active) rfHopSawHandoff=true;
+        if(!active&&current===ch){ $("#rfHandoffStatus").textContent=`Complete · Ch ${ch}`; terminal=true; break; }
+        if(!active&&rfHopSawHandoff){ $("#rfHandoffStatus").textContent=`Rolled back · Ch ${current}`; terminal=true; break; }
+        if(!active&&!rfHopSawHandoff&&i>=6){ $("#rfHandoffStatus").textContent=`Hop not started · stayed Ch ${current}`; terminal=true; break; }
+      }
+      await new Promise(resolve=>setTimeout(resolve,100));
+      await send([0x02,97,1]);
+      p=await readFrame(0xAD,13,256);
+    }
+    if(!terminal&&dev) $("#rfHandoffStatus").textContent=sawStatus?`Handoff still pending · Ch ${ch}`:"Hop status unavailable";
+    if(sawStatus) rfLastRefresh=Date.now();
+  }catch(e){
+    $("#rfHandoffStatus").textContent=`Hop error · Ch ${ch}`;
+    log("RF hop err: "+e.message);
+  }finally{
+    rfBusy=false;
+    rfHopRequestedCh=0;
+    setRfHopButtonsDisabled(rfHandoffActive);
+  }
+}
+
+async function manualStartupRf(ch){
+  if(!dev||isDongle||!rfCapable||!rfPoolChannels.has(ch)||rfSurveyLockActive) return;
+  if(!(await waitRfUiIdle())) return;
+  rfBusy=true;
+  try{
+    await waitIdle(); if(!dev) return;
+    await send([0x02,100,ch]);
+    const p=await readFrame(0xAD,13,256); if(p) applyRfStatus(p);
+    rfLastRefresh=Date.now();
+  }catch(e){ log("RF startup err: "+e.message); }
+  finally{ rfBusy=false; }
+}
+async function manualJournalBuilderRf(){
+  if(!dev||isDongle||!rfCapable||rfSurveyLockActive||rfBuilderSaving||rfBuilderStartPending) return;
+  if(rfBuilderActive){
+    if(!confirm("Cancel the RF Journal Builder? The current safe RF operation will finish, the puck will return to the channel that was active before the build, and the existing saved journal will be preserved.")) return;
+    if(!(await waitRfUiIdle())) return;
+    rfBusy=true;
+    try{
+      await waitIdle(); if(!dev) return;
+      await send([0x02,101,0]);
+      const p=await readFrame(0xAD,13,256); if(p) applyRfStatus(p);
+      rfLastRefresh=Date.now();
+    }catch(e){ log("RF journal builder cancel err: "+e.message); }
+    finally{ rfBusy=false; }
+    return;
+  }
+  const ok=confirm("RF Journal Builder\n\nPlace all connected controllers at approximately the farthest distance from the puck at which you normally use them, and leave them in their normal operating location during the test.\n\nThe puck will survey ambient RF, test all 14 recovery channels for about 60 seconds each, select the best channel, and save one completed RF journal record. The test takes roughly 15 minutes.\n\nStart the journal build?");
+  if(!ok) return;
+  rfBuilderStartPending=true;
+  $("#rfBuilderStatus").textContent="Starting…";
+  $("#rfBuilder").textContent="Starting…";
+  $("#rfBuilder").disabled=true;
+  if(!(await waitRfUiIdle())){
+    rfBuilderStartPending=false;
+    $("#rfBuilder").textContent="Build RF Journal";
+    syncRfBuilderDisabled();
+    return;
+  }
+  rfBusy=true;
+  let sawStatus=false;
+  try{
+    await waitIdle(); if(!dev) return;
+    await send([0x02,101,1]);
+    // Latch the Start button until firmware positively reports either an
+    // active Builder phase or a terminal failure. This closes the duplicate
+    // Start window while the first RF-status response is still propagating.
+    for(let i=0;i<20&&dev&&rfBuilderStartPending;i++){
+      const p=await readFrame(0xAD,13,256);
+      if(p){ sawStatus=true; applyRfStatus(p); }
+      if(!rfBuilderStartPending) break;
+      await new Promise(resolve=>setTimeout(resolve,100));
+      await send([0x02,97,1]);
+    }
+    if(rfBuilderStartPending){
+      rfBuilderStartPending=false;
+      $("#rfBuilderStatus").textContent=sawStatus?"Start outcome unknown · waiting for RF status":"Start status unavailable";
+      $("#rfBuilder").textContent="Build RF Journal";
+      syncRfBuilderDisabled();
+    }
+    if(sawStatus) rfLastRefresh=Date.now();
+  }catch(e){
+    rfBuilderStartPending=false;
+    $("#rfBuilder").textContent="Build RF Journal";
+    syncRfBuilderDisabled();
+    log("RF journal builder err: "+e.message);
+  }
+  finally{ rfBusy=false; }
+}
+async function manualSurveyRf(){
+  if(!dev||isDongle||!rfCapable||rfSurveyWatchActive||rfSurveyLockActive) return;
+  rfSurveyLockGeneration=rfSurveyGeneration;
+  rfSurveyLockActive=true;
+  rfSurveyWatchActive=true;
+  syncRfSurveyControls();
+  $("#rfSurveyState").textContent="requesting…";
+  if(!(await waitRfUiIdle())){
+    rfSurveyWatchActive=false;
+    rfSurveyLockActive=false;
+    syncRfSurveyControls();
+    return;
+  }
+  rfBusy=true;
+  const startGeneration=rfSurveyLockGeneration;
+  let sawSurvey=false, sawStatus=false, terminal=false;
+  try{
+    await waitIdle(); if(!dev) return;
+    await send([0x02,98,1]);
+    // A successful request is reflected immediately as pending/running in the
+    // dedicated RF-status frame. Poll faster than the 100-ms scanner step so
+    // per-channel progress and retries remain visible.
+    for(let i=0;i<400&&dev;i++){
+      const p=await readFrame(0xAD,13,256);
+      if(p){
+        sawStatus=true;
+        const flags=p[1], running=!!(flags&1), pending=!!(flags&2);
+        const generation=p[7]|(p[8]<<8);
+        applyRfStatus(p);
+        if(running||pending) sawSurvey=true;
+        if(rfSurveyFailure){ terminal=true; break; }
+        if(!running&&!pending&&generation!==startGeneration){ terminal=true; break; }
+        if(!running&&!pending&&!sawSurvey&&i>=2){
+          rfSurveyLockActive=false;
+          $("#rfSurveyState").textContent="Survey not started · RF workflow busy";
+          terminal=true;
+          break;
+        }
+      }
+      await new Promise(resolve=>setTimeout(resolve,50));
+      await send([0x02,97,1]);
+    }
+    if(!terminal){
+      if(rfSurveyRunning||rfSurveyPending) $("#rfSurveyState").textContent=rfSurveyRunning?"Survey still active":"Survey queued";
+      else if(!sawStatus){
+        rfSurveyLockActive=false;
+        $("#rfSurveyState").textContent="Survey status unavailable";
+      }
+    }
+    if(sawStatus) rfLastRefresh=Date.now();
+  }catch(e){
+    rfSurveyLockActive=false;
+    $("#rfSurveyState").textContent="Survey error";
+    log("RF survey err: "+e.message);
+  }
+  finally{
+    rfBusy=false;
+    rfSurveyWatchActive=false;
+    syncRfSurveyControls();
+  }
+}
+async function refreshRfStatus(survey=false){
+  if(!dev||isDongle||!rfCapable||rfBusy) return;
+  rfBusy=true;
+  try{
+    await waitIdle(); if(!dev) return;
+    await send([0x02,survey?98:97,1]);
+    const p=await readFrame(0xAD,13,256); if(p) applyRfStatus(p);
+    rfLastRefresh=Date.now();
+  }catch(e){ log("RF status err: "+e.message); }
+  finally{ rfBusy=false; }
+}
+
 // ---- per-slot tab state ----
 let g_activeSlot = 0;
 let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for bonded slots
@@ -939,6 +1345,12 @@ function applyBlob(p){
   const rumbleCap=(p[0]>=21 && p.length>193);
   const rumbleScale=rumbleCap?p[51]*2:200, rumbleStyle=rumbleCap?p[193]:0;
   const rssi=(p.length>37?p[37]:0);
+  // Reconciled firmware advertises RF recovery in the append-only v22 byte (firmware p[196] -> JS p[194]).
+  // Keep the old p[179] check so the same panel can still talk to the hardware-validated pre-reconcile RF build.
+  rfCapable=(p.length>194 && p[194]===0x52) || (p.length>179 && p[179]===0x52);
+  const rfCard=$("#rfRecoveryCard"); if(rfCard) rfCard.classList.toggle("hide", !rfCapable);
+  if(rfCapable&&Date.now()-rfLastRefresh>(rfHandoffActive||rfHopPending||rfSurveyRunning||rfSurveyPending?100:rfBuilderActive?500:2000)) refreshRfStatus(false);
+
 
   // per-slot link status (protocol v8, p.length >= 73)
   const hasSlotsData = p.length >= 73;
@@ -1173,7 +1585,7 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight||capturing||backupBusy||flightBusy||lizardBusy) return; inflight=true;
+  if(!dev||inflight||capturing||backupBusy||flightBusy||lizardBusy||rfBusy) return; inflight=true;
   // try/finally: an exception anywhere in here (applyBlob included) must never leave inflight latched true --
   // that would silently kill all future polls and read as a fake "heartbeat lost".
   try{
@@ -2316,6 +2728,8 @@ $("#rumbleTest").onclick=async()=>{ if(dev){ await send([0x16]); log("test rumbl
 $("#rumbleStyle").addEventListener("change", ()=>setField(39, +$("#rumbleStyle").value));
 $("#rumbleScale").addEventListener("change", ()=>setField(22, (+$("#rumbleScale").value)/2));
 $("#swGyroMap").addEventListener("change", ()=>setField(38, +$("#swGyroMap").value));
+$("#rfSurvey").onclick=()=>manualSurveyRf();
+$("#rfBuilder").onclick=()=>manualJournalBuilderRf();
 for(const sel of document.querySelectorAll("select.chord")){
   sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
 }

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.