Pārlūkot izejas kodu

limited slots for limited controllers + polling rate fix

Jariullah Safi 4 mēneši atpakaļ
vecāks
revīzija
4939312796

+ 4 - 2
OpenPuck/OpenPuck.ino

@@ -103,8 +103,10 @@ void setup()
 	if (puckMode) {
 		USBDevice.setSerialDescriptor(g_unit);
 	} else {
-		snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit,
-			 MODE_SUFFIX[g_usbMode - 1]);
+		// 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);
 	}
 

+ 9 - 0
OpenPuck/bonds.cpp

@@ -45,3 +45,12 @@ void loadBonds()
 		}
 	f.close();
 }
+
+int bondedSlotCount()
+{
+	int n = 0;
+	for (int i = 0; i < NSLOT; i++)
+		if (g_slot[i].used)
+			n++;
+	return n;
+}

+ 1 - 0
OpenPuck/bonds.h

@@ -30,3 +30,4 @@ extern bool g_pairing;
 bool recEmpty(const uint8_t *r);
 void loadBonds();
 void saveBonds();
+int bondedSlotCount();

+ 7 - 5
OpenPuck/mode_hidgyro.cpp

@@ -186,19 +186,21 @@ void HidGyroController::begin()
 {
 	USBDevice.setID(0x054C, 0x05C4);
 
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor.
-	// Bumped 0x0104 -> 0x0105 for the 4-DS4 enumeration (3 more HID interfaces vs the original).
-	USBDevice.setDeviceVersion(0x0105);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
 	USBDevice.setProductDescriptor("Wireless Controller");
 	initDs4Macs();
 	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
 		g_hidGyro[s].enableOutEndpoint(true);
 		g_hidGyro[s].setReportCallback(DS4_GETCB[s], DS4_SETCB[s]);
 		g_hidGyro[s].setReportDescriptor(GYRO_HID_DESC,
 						 sizeof GYRO_HID_DESC);
-
-		// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
 		g_hidGyro[s].setPollInterval(1);
 		g_hidGyro[s].begin();
 	}

+ 7 - 5
OpenPuck/mode_ps5.cpp

@@ -195,18 +195,20 @@ void Ps5Controller::begin()
 {
 	USBDevice.setID(0x054C, 0x0CE6);
 
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor.
-	// Bumped 0x0104 -> 0x0105 for the 4-DualSense enumeration (3 more HID interfaces vs the original).
-	USBDevice.setDeviceVersion(0x0105);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0110 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
 	USBDevice.setProductDescriptor("DualSense Wireless Controller");
 	initPs5Macs();
 	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
 		g_ps5[s].enableOutEndpoint(true);
 		g_ps5[s].setReportCallback(PS5_GETCB[s], PS5_SETCB[s]);
 		g_ps5[s].setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
-
-		// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
 		g_ps5[s].setPollInterval(1);
 		g_ps5[s].begin();
 	}

+ 7 - 5
OpenPuck/mode_switch_hori.cpp

@@ -169,17 +169,19 @@ void SwitchHoriController::begin()
 {
 	USBDevice.setID(0x0F0D, 0x0092);
 
-	// Windows caches config by VID:PID:bcdDevice, so any interface change MUST bump this. Bumped 0x0202 ->
-	// 0x0203 for the 4-HORIPAD enumeration (3 more HID interfaces vs the original single).
-	USBDevice.setDeviceVersion(0x0203);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0210 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
 	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
 		g_switch[s].enableOutEndpoint(true);
 		g_switch[s].setReportDescriptor(SWITCH_HID_DESC,
 						sizeof SWITCH_HID_DESC);
-
-		// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
 		g_switch[s].setPollInterval(1);
 		g_switch[s].begin();
 	}

+ 8 - 8
OpenPuck/mode_switch_pro.cpp

@@ -683,25 +683,25 @@ static jc_setcb_t const JC_SETCB[NSLOT] = { jcSet0, jcSet1, jcSet2, jcSet3 };
 void SwitchProController::begin()
 {
 	USBDevice.setID(0x057E, 0x2009);
-	// bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice). Bumped
-	// 0x0212 -> 0x0213 for the 4-Pro enumeration (3 more HID interfaces vs the original).
-	USBDevice.setDeviceVersion(0x0213);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0220 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setProductDescriptor("Pro Controller");
 	jcBuildStickCal();
 
-	// restore any persisted Switch motion calibration (per-unit IMU bias correction). Per-slot files.
 	loadUserCal();
-	swProLoadCfg(); // restore report-rate + gyro-scale settings
+	swProLoadCfg();
 	initJcMacs();
 	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
 		g_swPro[s].enableOutEndpoint(true);
-		// answer the Nintendo USB handshake + subcommands (else Steam never binds it)
 		g_swPro[s].setReportCallback(NULL, JC_SETCB[s]);
 		g_swPro[s].setReportDescriptor(SWPRO_HID_DESC,
 					       sizeof SWPRO_HID_DESC);
-
-		// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
 		g_swPro[s].setPollInterval(1);
 		g_swPro[s].begin();
 	}

+ 16 - 14
OpenPuck/mode_xinput.cpp

@@ -5,12 +5,9 @@
 // for the right trackpad. Host binds by VID/PID (045E:028E) + the FF/5D/01 interface. The OUT endpoint carries
 // rumble, relayed to the controller as a haptic by task().
 //
-// Multi-controller: 4 XInput interfaces, one per bond slot. The single class driver routes xfer/open callbacks
-// by endpoint address into the per-slot state. The host presents 4 XInput devices, mapped to user slots 1..4.
-// Rumble is per-slot (each XInput has its own OUT endpoint -> the right controller). The right-pad mouse is a
-// single shared interface (the desktop can only consume one mouse): slot 0's right-pad drives it; other slots'
-// right-pad is ignored for the mouse purpose. The right-pad-as-gamepad axis (touch/click) still flows through
-// the per-slot XInput HID so the host sees all four trackpads.
+// Multi-controller: one XInput interface per bonded controller slot. The single class driver routes xfer/open
+// callbacks by endpoint address into the per-slot state. The right-pad mouse is a single shared interface
+// (only slot 0's right-pad drives it). Number of registered interfaces matches bondedSlotCount() at boot.
 #include "mode_xinput.h"
 #include "triton.h"
 #include "gamepad_util.h"
@@ -86,18 +83,19 @@ static void xi_reset(uint8_t rhport)
 		g_xiSlot[s].inUse = false;
 	}
 }
-// TinyUSB calls xi_open once per XInput interface in the config descriptor. We claim the first free slot
-// (round-robin, but begin() fires them in order so the slot index matches the interface index from the
-// host's POV) and remember the endpoint addresses for the xfer_cb dispatch.
+// TinyUSB calls xi_open once per XInput interface in the config descriptor. Claim the first free BONDED
+// slot so the xiSlot index matches the bond-slot index (begin() registers them in ascending bond-slot order).
+// Fallback to any free slot when there are no bonds (fresh device).
 static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 			uint16_t max_len)
 {
 	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
 	      itf->bInterfaceProtocol == 0x01))
 		return 0;
+	int nBonds = bondedSlotCount();
 	int slot = -1;
 	for (int s = 0; s < NSLOT; s++) {
-		if (!g_xiSlot[s].inUse) {
+		if (!g_xiSlot[s].inUse && (nBonds == 0 || g_slot[s].used)) {
 			slot = s;
 			break;
 		}
@@ -414,13 +412,17 @@ void XboxController::begin()
 {
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
-	// Windows caches the config descriptor by VID:PID:bcdDevice, so any interface change here MUST bump bcdDevice
-	// or Windows serves a stale descriptor. Bumped 0x0115 -> 0x0116 for the 4-XInput enumeration (4 more IN + 4
-	// more OUT endpoints and 4 more interfaces vs the original single).
-	USBDevice.setDeviceVersion(0x0116);
+	// bcdDevice encodes the bonded count so Windows re-reads the config descriptor when the count changes.
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");
 	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
 		g_xinput[s].setStringDescriptor("Controller");
 		g_xinput[s].begin();
 	}

+ 99 - 62
OpenPuck/rf_link.cpp

@@ -57,7 +57,13 @@ volatile uint8_t g_battery[NSLOT] = { 0 };
 int g_curSlot = -1;
 
 // ---- internal counters / timers ----
-static uint8_t g_e3pid = 0;
+// ESB PID is 2 bits. The controller dequeues a FRESH report only when the poll's PID differs from the
+// last one it saw on that pipe; a repeated PID reads as a retransmit and returns the SAME (stale) report.
+// PER SLOT, because a single shared counter advances by (2 * nWarm) per cycle -- with 2 controllers that's
+// +4 per cycle = 0 mod 4, so each slot's GET PID is constant => the controller never dequeues => ~60 new/s
+// instead of ~400. Each slot's counter increments once per poll-of-that-slot so it cycles 0,1,2,3 cleanly.
+static uint8_t g_pollPid[NSLOT] = {};
+static uint8_t g_relayPid[NSLOT] = {};
 static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
 static unsigned long g_stMs = 0;
 // Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
@@ -66,6 +72,7 @@ static uint8_t g_lastSeq[NSLOT] = { 0 };
 static uint32_t g_stNew = 0;
 static uint32_t g_stCrc = 0, g_stNoRx = 0;
 static uint32_t g_chF1[3] = { 0, 0, 0 };
+// Cycle gate: fires once per g_pollUs; each fire polls every warm slot so all run at ~250 Hz.
 static uint32_t g_lastPollUs = 0;
 static uint32_t g_connRx = 0;
 static unsigned long g_lastSessBeacon = 0, g_lastDisc = 0;
@@ -599,30 +606,33 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 	return rxlen;
 }
 
-// Drive the connected-mode sequence one step per call. Round-robins the used slots, doing ONE slot's worth
-// of E7/relay/E3 per cycle. Per-slot poll rate = g_pollUs * usedSlotCount; with 1 controller 4ms/250Hz, with
-// 4 16ms/62.5Hz (matches the real puck's reported 4-controller pacing). g_curSlot is set before every TX
-// and consumed by the decode (g_in[g_curSlot]) and the haptic flush.
+// Throttle: bonded slots that haven't replied in SLOT_COLD_MS are polled at most every SLOT_COLD_RETRY_MS
+// instead of every cycle. This keeps the online controllers at full 250 Hz while barely touching offline ones.
+#define SLOT_COLD_MS 5000u
+#define SLOT_COLD_RETRY_MS 2000u
+static unsigned long g_slotLastAttemptMs[NSLOT] = {};
+
+// Drive the connected-mode sequence one step per call. Polls ONE bonded slot per call.
+// Gate fires at g_pollUs / nBonded so each warm slot gets ~250 Hz in round-robin order.
+// "Cold" means the slot WAS connected but has been silent for > SLOT_COLD_MS; slots that
+// have never replied stay warm so new controllers can connect at any time after boot.
+// Cold slots retry every SLOT_COLD_RETRY_MS with a full E7+relay+GET sequence.
 static void rfConnStep()
 {
-	// find the next used slot, starting AFTER the previous cycle's slot (round-robin).
-	int start = (g_curSlot < 0) ? 0 : ((g_curSlot + 1) % NSLOT);
-	int slot = -1;
-	for (int k = 0; k < NSLOT; k++) {
-		int s = (start + k) % NSLOT;
-		if (g_slot[s].used) {
-			slot = s;
+	int firstUsed = -1;
+	for (int k = 0; k < NSLOT; k++)
+		if (g_slot[k].used) {
+			firstUsed = k;
 			break;
 		}
-	}
-	if (slot < 0) {
+	if (firstUsed < 0) {
 		g_curSlot = -1;
 		return;
 	}
-	g_curSlot = slot;
+
 	uint8_t ch = g_sessCh;
-	// announce HOST AWAKE: E7 00 00, a few times
 	if (g_connSt == 0) {
+		g_curSlot = firstUsed;
 		uint8_t p[3] = { 0xE7, 0x00, g_e7b };
 		rfConnTx(ch, 0x01, p, 3);
 		if (++g_connStep >= 4) {
@@ -631,58 +641,69 @@ static void rfConnStep()
 			Serial.println(
 				"# CONN: awake announced -> polling GET report 0x45");
 		}
-		// poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
-	} else {
-		// CONTROLLED CADENCE: poll ~every g_pollUs
-		if ((uint32_t)(micros() - g_lastPollUs) < g_pollUs)
-			return;
-		// FIXED-RATE schedule: advance the deadline by exactly one interval (NOT reset-to-now, which would add
-		// each cycle's ~1ms of poll work + loop jitter to the next interval -- the period drifts to ~5000us
-		// instead of the 4000us target). The resync guard prevents a catch-up burst after a real stall.
-		{
-			uint32_t now = micros();
-			static uint32_t lastFire = 0;
-			// MEASURED actual period
-			if (lastFire) {
-				g_pollDtSum += (uint32_t)(now - lastFire);
-				g_pollDtCnt++;
-			}
-			lastFire = now;
-			g_lastPollUs += g_pollUs;
-			// fell >1 interval behind -> resync
-			if ((uint32_t)(now - g_lastPollUs) >= g_pollUs)
-				g_lastPollUs = now;
+		return;
+	}
+
+	// Cycle gate: fires once per g_pollUs (250 Hz). On each fire we poll EVERY warm slot
+	// back-to-back so all bonded controllers run at full rate -- the real puck services all
+	// controllers per cycle. Polling one-slot-per-call instead tied the per-slot rate to the
+	// loop frequency AND split it across slots, collapsing 2 controllers to ~90 Hz.
+	uint32_t nowUs = micros();
+	if ((uint32_t)(nowUs - g_lastPollUs) < (uint32_t)g_pollUs)
+		return;
+	{
+		// Cycle period stat: time between gate fires (= each slot's poll period, intended g_pollUs).
+		static uint32_t lastCycleUs = 0;
+		if (lastCycleUs) {
+			g_pollDtSum += (uint32_t)(nowUs - lastCycleUs);
+			g_pollDtCnt++;
 		}
+		lastCycleUs = nowUs;
+	}
+	g_lastPollUs += g_pollUs;
+	if ((uint32_t)(nowUs - g_lastPollUs) >= (uint32_t)g_pollUs)
+		g_lastPollUs = nowUs; // catch-up reset when a cycle overran
+
+	unsigned long nowMs = millis();
+
+	// Inline poll helper; emits E7 re-assert (every 32 polls, bounded reply window so a
+	// missed F3 doesn't burn the whole slot budget), queues haptics, flushes relay, sends E3 GET.
+	auto doPoll = [&](int k) {
+		g_slotLastAttemptMs[k] = nowMs;
+		g_curSlot = k;
 		if ((g_connPoll & 0x1F) == 0) {
 			uint8_t pa[3] = { 0xE7, 0x00, g_e7b };
-			rfConnTx(ch, 0x01, pa, 3);
-		} // re-assert awake/version
+			rfConnTx(ch, 0x01, pa, 3, 600);
+		}
 		rfConnQueueHapticRelay();
-		// Relay (if any) gets its OWN cycled PID, then the GET poll gets the NEXT one -- so they're always distinct
-		// and the controller never dedups the GET as a retransmit of the relay (that was dropping ~half the replies
-		// during haptics). Advancing g_e3pid even when no relay is pending just skips a PID value (harmless).
 		{
-			uint8_t rs1 = (uint8_t)((((g_e3pid++) & 3) << 1) | 1);
+			uint8_t rs1 =
+				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
 			rfConnFlushRelay(ch, rs1);
 		}
-		{
-			// E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
-			uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45, g_getParam };
-			// cycle PID (S1 1,3,5,7), NO_ACK=1
-			// cycle PID (S1 0,2,4,6), NO_ACK=0
-			// fixed (matches captured puck poll)
-			uint8_t s1 =
-				(g_e3mode == 1) ?
-					(uint8_t)((((g_e3pid++) & 3) << 1) |
-						  1) :
-				(g_e3mode == 2) ?
-					(uint8_t)(((g_e3pid++) & 3) << 1) :
-					0x07;
-			uint8_t rx = rfConnTx(ch, s1, p, 5);
-			if (rx)
-				g_chF1[0]++;
-		}
+		uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45, g_getParam };
+		uint8_t pidv = g_pollPid[k]++;
+		uint8_t s1 = (g_e3mode == 1) ?
+				     (uint8_t)(((pidv & 3) << 1) | 1) :
+			     (g_e3mode == 2) ? (uint8_t)((pidv & 3) << 1) :
+					       0x07;
+		uint8_t rx = rfConnTx(ch, s1, p, 5);
+		if (rx)
+			g_chF1[0]++;
 		g_connPoll++;
+	};
+
+	// Poll every warm slot this cycle. "Cold" = was connected, now silent > SLOT_COLD_MS;
+	// those are retried only every SLOT_COLD_RETRY_MS. Never-replied slots count as warm so
+	// a controller that connects at any time gets the full poll rate to establish its link.
+	for (int k = 0; k < NSLOT; k++) {
+		if (!g_slot[k].used)
+			continue;
+		bool cold = g_connReplyMs[k] != 0 &&
+			    nowMs - g_connReplyMs[k] > SLOT_COLD_MS;
+		if (cold && nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+			continue;
+		doPoll(k);
 	}
 }
 
@@ -694,6 +715,16 @@ void rfLinkTask()
 	// that's powering off isn't immediately re-woken/reconnected.
 	if (g_rfHost && millis() - g_connCooldown > 2500) {
 		bool connNow = anySlotLinkUp();
+		// allUp = every BONDED slot currently has a link. Discovery stays aggressive until then so a
+		// controller joining after another is already connected still finds the puck quickly -- throttling
+		// discovery the moment ONE slot links up was starving the late joiner of beacons (it never showed up).
+		bool allUp = true;
+		for (int s = 0; s < NSLOT; s++)
+			if (g_slot[s].used &&
+			    millis() - g_connReplyMs[s] >= 300) {
+				allUp = false;
+				break;
+			}
 		// session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
 		// (every-loop beaconing also hammers the session ch and steals reply slots from the poll)
 		if (millis() - g_lastSessBeacon >= (connNow ? 25u : 0u)) {
@@ -702,8 +733,14 @@ void rfLinkTask()
 			for (int s = 0; s < NSLOT; s++)
 				rfHostFrameOnce(s, false);
 		}
-		// discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
-		if (millis() - g_lastDisc >= (connNow ? 200u : 0u)) {
+		// discovery beacon on ch2 (where a searching controller looks). Cadence:
+		//   nobody connected  -> every loop (fastest cold-boot connect)
+		//   some but not all   -> 40 ms (~25 Hz): a late joiner still connects in ~1 s without the
+		//                          every-loop beacon stealing the connected controller's poll budget --
+		//                          important when a slot is bonded but its controller is left off
+		//   all connected      -> 200 ms (keep the rendezvous warm)
+		uint32_t discEvery = allUp ? 200u : (connNow ? 40u : 0u);
+		if (millis() - g_lastDisc >= discEvery) {
 			g_lastDisc = millis();
 			g_rfCh = 2;
 			for (int s = 0; s < NSLOT; s++)

+ 19 - 5
OpenPuck/webusb_config.cpp

@@ -11,14 +11,15 @@
 
 Adafruit_USBD_WebUSB usb_web;
 
-// blob payload = [ver=2][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
+// blob payload = [ver=8][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
 //                [gitDirty][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
-// keep the blob under 64 bytes total (readBlob reads one 64-byte packet)
-#define WB_PAYLEN 60
+//                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
+// v8 extends to 73 bytes (75 total incl header); browser reads with transferIn(128) to span the two USB-FS packets.
+#define WB_PAYLEN 73
 static void webusbSendBlob()
 {
 	if (!usb_web.connected())
@@ -32,8 +33,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (7 = +raw accel; 6 = +swPro120/gyroScale; 5 = +rumbleScale)
-	p[2] = 7;
+	// protocol version (8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 8;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -99,6 +100,19 @@ static void webusbSendBlob()
 		int16_t a[3] = { g_in[0].ax, g_in[0].ay, g_in[0].az };
 		memcpy(&p[56], a, 6);
 	} // raw accelerometer for scale diagnostics (protocol v7)
+
+	// per-slot link status for all bond slots (protocol v8)
+	p[62] = (uint8_t)bondedSlotCount();
+	{
+		unsigned long nowMs = millis();
+		for (int s = 0; s < NSLOT; s++) {
+			bool sup = g_slot[s].used && g_connReplyMs[s] != 0 &&
+				   (nowMs - g_connReplyMs[s]) < 300u;
+			p[63 + s * 3] = sup ? 1 : 0;
+			p[64 + s * 3] = g_battery[s];
+			p[65 + s * 3] = g_linkRssi[s];
+		}
+	}
 	usb_web.write(p, sizeof p);
 	usb_web.flush();
 }

+ 133 - 50
docs/index.html

@@ -33,6 +33,20 @@
   .note{color:var(--mut);font-size:12px;margin-top:10px}
   #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:120px;overflow:auto}
   .hide{display:none}
+
+  /* ---- controller slot tabs ---- */
+  .slot-tabs{display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap}
+  .slot-tab{display:flex;align-items:center;gap:7px;padding:5px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#0e1017;border:1px solid #232a3a;color:var(--mut);cursor:pointer;transition:border-color .15s,color .15s,background .15s}
+  .slot-tab:hover{border-color:var(--acc2);color:var(--fg)}
+  .slot-tab.active{background:#111c2e;border-color:var(--acc2);color:var(--fg)}
+  .slot-tab .dot{width:7px;height:7px;border-radius:50%;background:var(--bad);flex-shrink:0;transition:background .3s}
+  .slot-tab .dot.up{background:var(--acc);animation:pulse-link 2.2s ease-in-out infinite}
+  @keyframes pulse-link{
+    0%,100%{box-shadow:0 0 0 0 rgba(91,214,160,.5)}
+    55%{box-shadow:0 0 0 5px rgba(91,214,160,0)}
+  }
+  @media(prefers-reduced-motion:reduce){.slot-tab .dot.up{animation:none}}
+  .per-slot-grid{margin-bottom:12px}
 </style>
 </head>
 <body>
@@ -59,9 +73,6 @@
         <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
         <button data-mode="5" class="modebtn">PS5 DualSense</button>
         <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
-        <!-- modes 7/8 (PS5/DS4 "game/clean") hidden from the panel for now; still reachable via serial console -->
-        <!-- <button data-mode="7" class="modebtn">PS5 (game/clean)</button> -->
-        <!-- <button data-mode="8" class="modebtn">DS4 (game/clean)</button> -->
       </div>
       <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only.</p>
       <div class="row"><label>Persist last mode</label>
@@ -80,11 +91,19 @@
 
     <div class="card">
       <h2>Link status</h2>
-      <div class="statgrid">
+
+      <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
+      <div class="slot-tabs" id="slotTabs" style="display:none"></div>
+
+      <!-- Per-slot: RF link / Battery / Signal -->
+      <div class="statgrid per-slot-grid">
         <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
         <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
         <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
-        <div class="stat"><div class="k">Slot</div><div class="v" id="stSlot">—</div></div>
+      </div>
+
+      <!-- Global stats -->
+      <div class="statgrid">
         <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
         <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
         <div class="stat logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
@@ -137,7 +156,6 @@
       <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
     </div>
 
-
     <div class="card">
       <h2>Button mapping</h2>
       <div class="row"><label>A/B + X/Y swap</label>
@@ -185,31 +203,60 @@ for(const sel of document.querySelectorAll("select.back")){
   for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
 }
 for(const sel of document.querySelectorAll("select.chord")){
-  MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;   // hide PS5/DS4 "game/clean" modes from the panel for now
+  MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
     const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
 }
-// Switch Pro report-rate options (0=66Hz, 1=120Hz default, 2=full ~250Hz)
 { const sel=document.getElementById("swRate");
   for(const [lbl,v] of [["120 Hz (default)",1],["66 Hz (compatibility)",0],["Full rate (~250 Hz)",2]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
-// Switch Pro gyro-scale options (value = scale x10, so it fits in one byte)
 { const sel=document.getElementById("swGyro");
   for(const [lbl,v] of [["0.5×",5],["1.0×",10],["1.5×",15],["2.0×",20],["2.5×",25],["3.0×",30]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
 
+// ---- per-slot tab state ----
+let g_activeSlot = 0;
+let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for bonded slots
+
+function updateSlotDisplay(slotIdx) {
+  const d = g_slotData[slotIdx];
+  if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—"; return; }
+  $("#stLink").innerHTML = d.up ? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
+  $("#stBatt").textContent = (d.up && d.battery) ? d.battery+"%" : "—";
+  $("#stRssi").textContent = (d.up && d.rssi) ? ("-"+d.rssi+" dBm") : "—";
+}
+
+function renderSlotTabs(bondedCount) {
+  const bar = $("#slotTabs");
+  if (bondedCount <= 1) { bar.style.display="none"; updateSlotDisplay(g_activeSlot); return; }
+  bar.style.display="flex";
+  bar.innerHTML="";
+  for (let s=0; s<4; s++) {
+    const d = g_slotData[s];
+    if (!d) continue;
+    const btn = document.createElement("button");
+    btn.className = "slot-tab" + (s===g_activeSlot ? " active" : "");
+    const dot = document.createElement("span");
+    dot.className = "dot" + (d.up ? " up" : "");
+    btn.appendChild(dot);
+    btn.appendChild(document.createTextNode("Controller "+(s+1)));
+    btn.onclick = () => { g_activeSlot=s; renderSlotTabs(bondedCount); };
+    bar.appendChild(btn);
+  }
+  updateSlotDisplay(g_activeSlot);
+}
+
 async function connect(){
   try{
     dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
     await dev.open();
     if(dev.configuration===null) await dev.selectConfiguration(1);
-    // WebUSB = vendor class 0xFF with bulk IN + bulk OUT (skip XInput 0xFF/5D/01 which uses interrupt only)
     let found=null;
     for(const itf of dev.configuration.interfaces){
       const a=itf.alternate;
       if(a.interfaceClass!==0xFF) continue;
-      if(a.interfaceSubClass===0x5D) continue;   // Xbox XInput vendor
+      if(a.interfaceSubClass===0x5D) continue;
       let bin=0,bout=0;
       for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
       if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
@@ -217,14 +264,11 @@ async function connect(){
     if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the device"); return; }
     ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
     await dev.claimInterface(ifNum);
-    // WebUSB "connect" handshake: Adafruit_USBD_WebUSB only enables write() / connected()
-    // after the host sends SET_CONTROL_LINE_STATE (bRequest 0x22, value=1) on this interface.
     await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
     $("#connState").textContent="connected"; $("#connState").className="pill up";
     $("#connectBtn").textContent="Reconnect";
     $("#panel").classList.remove("hide");
     log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
-    dev.onUSBConnectionEvent;
     navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
     startPolling();
   }catch(e){ log("connect failed: "+e.message); }
@@ -242,10 +286,10 @@ async function send(bytes){
 }
 async function readBlob(){
   try{
-    const r = await dev.transferIn(epIn, 64);
+    // v8 blob is 75 bytes (two USB-FS packets); read 128 to capture both in one call.
+    const r = await dev.transferIn(epIn, 128);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
-    // find 0xA5 frame
     let i=0; while(i<d.length && d[i]!==0xA5) i++;
     if(i+2>d.length) return null;
     const len=d[i+1]; const p=d.slice(i+2, i+2+len);
@@ -253,52 +297,94 @@ async function readBlob(){
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 function applyBlob(p){
-  // [ver][mode][mDiv][mFric][rsvd][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100(unused)][newlo][newhi]
-  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];   // p[4] = QAM remap code (0 = default)
+  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];
   const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
-  const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];   // v2 blob: chordBtn after qos/persist (Y defaults to Switch Pro=4)
-  const polls=(p.length>27?(p[26]|(p[27]<<8)):0);          // poll TX rate
-  const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);         // loop diag
+  const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
+  const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
+  const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);
   const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
   const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
-  const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);         // MEASURED poll period
-  const pollIntended=(p.length>14?p[14]*100:0);            // intended poll interval (g_pollUs)
-  const logEnabled=(p.length>35?p[35]:0);                  // is this a logging build (-DOPK_LOG=1)?
+  const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);
+  const pollIntended=(p.length>14?p[14]*100:0);
+  const logEnabled=(p.length>35?p[35]:0);
   for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
-  // mode buttons
   for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
   $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
-  // status
-  const battery=(p.length>36?p[36]:0);                    // controller battery % (report 0x43); 0 = unknown
-  const rumbleScale=(p.length>51?p[51]:200);              // rumble strength % (protocol v5+; older blob -> default)
-  const swProRate=(p.length>52?p[52]:1);                  // Switch Pro report rate 0/1/2 (protocol v6+; default 120Hz)
-  const swGyro10=(p.length>53?p[53]:10);                  // Switch Pro gyro scale x10 (protocol v6+)
-  const rssi=(p.length>37?p[37]:0);                       // RAW signal strength |dBm| (0 = no sample)
-  $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
-  $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
-  $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
-  $("#stSlot").textContent = slot===0xFF? "—" : slot;
+  const battery=(p.length>36?p[36]:0);
+  const rumbleScale=(p.length>51?p[51]:200);
+  const swProRate=(p.length>52?p[52]:1);
+  const swGyro10=(p.length>53?p[53]:10);
+  const rssi=(p.length>37?p[37]:0);
+
+  // per-slot link status (protocol v8, p.length >= 73)
+  const hasSlotsData = p.length >= 73;
+  if (hasSlotsData) {
+    const bondedN = p[60];
+    g_slotData = [];
+    for (let s=0; s<4; s++) {
+      const base = 61 + s*3;
+      if (p.length >= base+3) {
+        // only include bonded slots (a slot with up=0 could be bonded but offline)
+        // check if the slot appears at all in the bonded map by checking if bondedN covers it;
+        // the firmware only sets non-zero battery/rssi for actually-bonded slots
+        const slotUp = !!p[base];
+        const slotBatt = p[base+1];
+        const slotRssi = p[base+2];
+        // slot is "bonded" if firmware included it in bondedN coverage
+        // we populate slotData for all 4 and renderSlotTabs skips gaps
+        g_slotData[s] = {up: slotUp, battery: slotBatt, rssi: slotRssi};
+      }
+    }
+    // keep only bonded slots by cross-referencing bondedN
+    // firmware sends bondedSlotCount but not a slot bitmask; use battery>0 || rssi>0 || up as presence hint
+    // simpler: just count how many of the 4 slots have any non-zero data OR are marked up
+    // Actually the firmware always writes all 4 slots; we filter by the bond system's "used" flag via presence.
+    // For clean display: we'll show tabs only for slots that have ever had a reply OR are bonded (battery/rssi set).
+    // The firmware sets g_battery[s]=0 and g_linkRssi[s]=0 for unused slots, so 0/0/0 = not bonded.
+    // But a bonded offline slot also has 0/0/0 initially. We rely on bondedN for count and show the first bondedN slots
+    // that have g_slot[s].used — but we can't easily know which slots are used from the blob.
+    // Best heuristic: show all 4 slots if bondedN==4, otherwise use slot indices where battery or rssi != 0 OR up==1,
+    // OR fall back to showing slots 0..bondedN-1 if none have data yet (device just booted).
+    let bondedSlots = [];
+    for (let s=0; s<4; s++) {
+      if (g_slotData[s] && (g_slotData[s].up || g_slotData[s].battery || g_slotData[s].rssi)) bondedSlots.push(s);
+    }
+    // fallback: if no data yet (all offline, just booted), show slots 0..bondedN-1
+    if (bondedSlots.length === 0 && bondedN > 0) {
+      for (let s=0; s<bondedN; s++) bondedSlots.push(s);
+    }
+    // remove slotData for non-bonded slots so tabs don't appear for them
+    const bondedSet = new Set(bondedSlots);
+    for (let s=0; s<4; s++) { if (!bondedSet.has(s)) g_slotData[s] = null; }
+    // clamp active slot to a valid bonded slot
+    if (!g_slotData[g_activeSlot]) g_activeSlot = bondedSlots[0] || 0;
+    renderSlotTabs(bondedN);
+  } else {
+    // old firmware: single-slot display
+    $("#slotTabs").style.display="none";
+    $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
+    $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
+    $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
+  }
+
+  // global stats
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
   $("#stPolls").textContent = up? polls+" /s" : "—";
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
-  // build provenance (protocol v4+): dirty flag at p[38], 12-byte NUL-padded git hash at p[39..50]. Independent
-  // of link state. Older firmware sends a shorter blob -> stays "—".
   const gitDirty=(p.length>38?p[38]:0); let gitHash="";
   for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
   $("#stBuild").innerHTML = gitHash
     ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
     : "—";
-  // sliders (only if not being dragged)
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
   { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
-  // raw accelerometer diagnostic (protocol v7): 3x s16 LE at p[54..59]
   if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
     const ax=s16(54),ay=s16(56),az=s16(58);
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
@@ -313,7 +399,7 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight||capturing) return; inflight=true;   // don't let the status poll steal capture frames
+  if(!dev||inflight||capturing) return; inflight=true;
   await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
   inflight=false;
 }
@@ -322,9 +408,9 @@ async function setField(field,value){
 }
 function fmtCapEntry(e,maxMs){
   const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
-  const t = `-${String(maxMs-e.ms).padStart(6," ")}ms  `;   // age vs the newest entry (consistent across drain batches)
+  const t = `-${String(maxMs-e.ms).padStart(6," ")}ms  `;
   if(e.slot===0xFD){ const k=e.bytes[0]; return `${t}════════ ${k===2?"RECONNECT (block+reinit armed)":k===1?"RF LINK UP":"RF LINK DOWN"} ════════`; }
-  const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot}     `;                        // 0xFE = frame WE sent the controller
+  const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot}     `;
   return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")}  n=${e.nb}:  ${hex}`;
 }
 function downloadCap(){
@@ -333,11 +419,10 @@ function downloadCap(){
   a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
   a.download="puck-capture.txt"; a.click();
 }
-// ---- live Start/Stop capture: poll the firmware's drain (op 0x06) on a timer and accumulate in the browser ----
-let liveTimer=null, draining=false, capLines=[];   // capLines = array of {ms,slot,rid,nb,bytes}
+let liveTimer=null, draining=false, capLines=[];
 function renderCap(){
   if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
-  const maxMs=capLines[capLines.length-1].ms;       // entries are chronological -> newest is last
+  const maxMs=capLines[capLines.length-1].ms;
   const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
   $("#capOut").textContent=t; window._lastCap=t;
 }
@@ -372,15 +457,15 @@ async function drainTick(){
 }
 async function startCapture(){
   if(!dev||capturing) return;
-  capturing=true; capLines=[];                       // capturing=true pauses the status poll so it can't steal frames
+  capturing=true; capLines=[];
   $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
   $("#capStart").disabled=true; $("#capStop").disabled=false;
-  await send([0x05,1]);                               // rewind drain to OLDEST -> dumps the whole ring from boot
+  await send([0x05,1]);
   liveTimer=setInterval(drainTick,100);
 }
 async function stopCapture(){
   if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
-  await drainTick();                                  // final flush
+  await drainTick();
   try{ await send([0x05,0]); }catch(e){}
   capturing=false;
   $("#capStart").disabled=false; $("#capStop").disabled=true;
@@ -410,12 +495,10 @@ $("#dfuUf2").onclick=async()=>{
 };
 $("#factoryErase").onclick=async()=>{
   if(!dev){ log("not connected"); return; }
-  // Triple gate so this is impossible to fire accidentally: warn, warn harder, then require typing ERASE.
   if(!confirm("Factory erase?\n\nThis wipes ALL persistent storage on the copycat:\n  • the paired-controller bond (you'll have to re-pair)\n  • every saved setting (mode, chords, back paddles, sensitivity)\n\nThe copycat reboots to factory defaults. This CANNOT be undone.")) return;
   if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
   const typed=prompt('Final confirmation — type  ERASE  (all caps) to wipe everything:');
   if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
-  // op 0x0A + magic "ERS" (0x45,0x52,0x53) -- firmware reformats the FS and reboots; the device drops off USB.
   await send([0x0A,0x45,0x52,0x53]);
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };