Browse Source

Make one device being disconnected less catastrophic

Jari 3 tháng trước cách đây
mục cha
commit
84eb2e99b7

+ 3 - 3
OpenPuck/config.cpp

@@ -158,10 +158,10 @@ void loadCfg()
 			}
 			g_hapticBlockOn = c.hapticBlockOn ? 1 : 0;
 			// clamp seconds so s*1000 fits the uint16 ms field (max 60s)
-			g_hapticBlockMs =
-				(uint16_t)((c.hapticBlockS > 60 ? 60 :
+			g_hapticBlockMs = (uint16_t)((c.hapticBlockS > 60 ?
+							      60 :
 							      c.hapticBlockS) *
-					   1000u);
+						     1000u);
 		}
 		f.close();
 	}

+ 5 - 5
OpenPuck/fault_diag.cpp

@@ -11,9 +11,8 @@ static uint8_t g_reason = RR_UNKNOWN;
 static uint32_t g_resetReas = 0;
 
 static const char *const REASON_STR[RR_COUNT] = {
-	"unknown",	   "power-on",	   "pin/replug",
-	"WATCHDOG (hang)", "CPU lockup",   "HARDFAULT",
-	"reboot",	   "soft reset",   "wake-from-off",
+	"unknown",   "power-on", "pin/replug", "WATCHDOG (hang)", "CPU lockup",
+	"HARDFAULT", "reboot",	 "soft reset", "wake-from-off",
 };
 
 // HardFault override. The Adafruit core's default handler (cores/.../debug.cpp) already does NVIC_SystemReset();
@@ -36,7 +35,8 @@ void faultDiagArmIntentionalReset()
 
 void faultDiagBoot()
 {
-	uint32_t rr = readResetReason(); // latched + cleared by the core's init()
+	uint32_t rr =
+		readResetReason(); // latched + cleared by the core's init()
 	uint8_t g2 = (uint8_t)NRF_POWER->GPREGRET2;
 	NRF_POWER->GPREGRET2 = 0; // consume the marker for this boot cycle
 	g_resetReas = rr;
@@ -51,7 +51,7 @@ void faultDiagBoot()
 	else if (rr & POWER_RESETREAS_LOCKUP_Msk)
 		reason = RR_LOCKUP;
 	else if (rr & POWER_RESETREAS_SREQ_Msk)
-		reason = (g2 == G2_FAULT) ? RR_HARDFAULT :
+		reason = (g2 == G2_FAULT)  ? RR_HARDFAULT :
 			 (g2 == G2_INTENT) ? RR_REBOOT :
 					     RR_SOFT;
 	else if (rr & POWER_RESETREAS_OFF_Msk)

+ 2 - 1
OpenPuck/haptics.cpp

@@ -398,7 +398,8 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 			// slot was already consumed under the critical section above.
 			// s1 carries a PID distinct from the GET poll (caller cycles it) so the controller's ESB
 			// dedup never treats the GET as a retransmit of this relay. 80us RX: relay is NO-ACK.
-			rfConnTx(ch, s1, p, plen, 80); // one relay per poll cycle
+			rfConnTx(ch, s1, p, plen,
+				 80); // one relay per poll cycle
 		}
 	}
 }

+ 4 - 2
OpenPuck/haptics.h

@@ -49,8 +49,10 @@ bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 
 // Post-connect haptic block (persisted, panel-controlled): when g_hapticBlockOn, Steam haptics are dropped for
 // g_hapticBlockMs after a (re)connect so the controller's haptic engine settles before the first real haptic.
-extern uint8_t g_hapticBlockOn; // 1 = block enabled, 0 = relay haptics immediately on connect (default)
-extern uint16_t g_hapticBlockMs; // block duration in ms (default HAPTIC_BLOCK_MS_DEFAULT)
+extern uint8_t
+	g_hapticBlockOn; // 1 = block enabled, 0 = relay haptics immediately on connect (default)
+extern uint16_t
+	g_hapticBlockMs; // block duration in ms (default HAPTIC_BLOCK_MS_DEFAULT)
 
 // anything still queued (xinput uses it to pace rumble re-queues)
 bool relayPending();

+ 26 - 2
OpenPuck/usb_mount.cpp

@@ -15,6 +15,12 @@ static uint8_t g_maxSlots = NSLOT;
 // The connected set must hold steady this long before we re-enumerate -- absorbs RF blips and the staggered
 // way multiple controllers connect at boot (one re-enumeration once the set settles, not one per controller).
 #define MOUNT_DEBOUNCE_MS 2000u
+// Lazy removal: a disconnected slot's interface is only dropped (and the device re-enumerated down) once NO
+// controller has sent input for this long -- i.e. during genuine downtime. Re-enumeration tears down/rebuilds
+// USB from the loop task and is risky to do mid-use, so we never shrink while anything is being played; we
+// just let dead slots linger as idle interfaces and clean them up when the rig goes quiet. Additions are NOT
+// gated by this -- a newly-connected controller grows the set right away.
+#define IDLE_CLEANUP_MS 60000u
 
 static uint8_t connectedMask()
 {
@@ -108,14 +114,32 @@ void usbMountTask()
 	static uint8_t lastMask = 0xFF; // force first compare
 	static unsigned long stableSince = 0;
 	unsigned long now = millis();
-	uint8_t want = connectedMask();
+	// ADD IMMEDIATELY, REMOVE LAZILY. Re-enumeration (usbReenumerate) tears down + rebuilds the whole USB
+	// config from the loop task; doing that while the high-priority usbd task is mid-tud_task races its
+	// internal state -> HardFault/reboot. So we only ever SHRINK the interface set during genuine downtime:
+	//   - active (some controller sent input within IDLE_CLEANUP_MS): target = connected UNION already-mounted
+	//     -> the set only grows. A controller that drops (e.g. an errant ReversePuck disconnect) keeps its
+	//     now-idle interface and reconnecting reuses it with NO re-enumeration. task() gates input per slot,
+	//     so an idle interface just streams nothing.
+	//   - idle (no controller input for a full minute): target = exactly the connected set -> any lingering
+	//     dead slot is dropped and we re-enumerate down, safely, because nobody is playing.
+	// New controllers grow the set right away (after the usual debounce) regardless of idle state.
+	unsigned long lastAny = 0;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] > lastAny)
+			lastAny = g_connReplyMs[s];
+	bool idle = (lastAny == 0) || (now - lastAny) >= IDLE_CLEANUP_MS;
+	uint8_t want = idle ? connectedMask() :
+			      (uint8_t)(connectedMask() | mountedMask());
 	if (want != lastMask) { // set is moving -> restart the stability timer
 		lastMask = want;
 		stableSince = now;
 		return;
 	}
 	// `want` has been steady since stableSince. Re-enumerate once it has held long enough AND the resulting
-	// (order-preserving, capped) mount SET differs from what is currently mounted.
+	// (order-preserving, capped) mount SET differs from what is currently mounted. With the union above,
+	// while active that can only happen when a NEW controller needs an interface (grow); a shrink only ever
+	// comes from the idle branch dropping a long-dead slot.
 	if ((now - stableSince) < MOUNT_DEBOUNCE_MS)
 		return;
 	int8_t tmp[NSLOT];

+ 3 - 2
OpenPuck/webusb_config.cpp

@@ -410,7 +410,7 @@ void webusbPoll()
 				// lands late under haptic load; narrower allows a higher poll rate. Clamp 600..3000us.
 				case 25: {
 					uint32_t w = (uint32_t)v * 10;
-					g_rxWin = w < 600 ? 600 :
+					g_rxWin = w < 600  ? 600 :
 						  w > 3000 ? 3000 :
 							     w;
 					break;
@@ -424,7 +424,8 @@ void webusbPoll()
 				// post-connect haptic block duration, in seconds (clamp 0..60)
 				case 28:
 					g_hapticBlockMs =
-						(uint16_t)((v > 60 ? 60 : v) * 1000u);
+						(uint16_t)((v > 60 ? 60 : v) *
+							   1000u);
 					break;
 				}
 				if (persist)

+ 83 - 13
ReversePuck/ui.py

@@ -1,12 +1,19 @@
 """ui.py -- fullscreen touchscreen UI for the Steam Deck forwarder.
 
 Big tappable tiles, one per bonded puck. A tile is tappable when its puck is LIVE (the RF link is up).
-Tapping a live tile detaches the Deck controls and forwards to that puck; tapping anywhere while
-forwarding stops and returns control to the Deck. Sized for the Deck's 1280x800 touchscreen but scales.
+Tapping a live tile detaches the Deck controls and forwards to that puck (a single tap -- connecting is
+cheap and reversible). The two DESTRUCTIVE actions -- stopping/disconnecting a forward, and removing a
+bond (unpair) -- instead require a 5-SECOND HOLD: an errant screen tap mid-game used to drop the forward
+(which on the puck side tears down a USB device and could reboot the MCU), so a brief touch must no longer
+trigger it. A progress fill shows the hold; lifting before it completes cancels. Sized for the Deck's
+1280x800 touchscreen but scales.
 """
 import time
 import pygame
 
+# Destructive actions (stop forwarding, remove bond) require holding this long; a tap no longer triggers them.
+HOLD_SECS = 5.0
+
 BG = (16, 18, 24)
 FG = (230, 232, 238)
 DIM = (120, 126, 140)
@@ -37,6 +44,15 @@ def run(app):
 
     tile_rects = []  # (rect, slot, tappable)
     del_rects = []   # (rect, slot) -- the per-tile "remove bond" ✕ target
+    # Active hold-to-confirm gesture, or None. dict(kind="stop"|"unpair", slot, start). Read by draw() for
+    # the progress fill; set in handle_down()/cleared in handle_up() and on completion.
+    hold = None
+
+    def hold_progress():
+        """0.0..1.0 of the current hold, or None if no hold is active."""
+        if hold is None:
+            return None
+        return max(0.0, min(1.0, (time.monotonic() - hold["start"]) / HOLD_SECS))
 
     def draw():
         nonlocal tile_rects, del_rects
@@ -88,7 +104,7 @@ def run(app):
             screen.blit(f_big.render(glyph, True, gcol), (rect.x + 30, rect.y + th // 2 - 22))
             screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
                         (rect.x + 90, rect.y + 20))
-            state = ("FORWARDING — tap to stop" if sel
+            state = ("FORWARDING — hold anywhere to stop" if sel
                      else "available — tap to forward" if tappable
                      else "live (forwarding elsewhere)" if b["alive"]
                      else "paired · offline")
@@ -100,10 +116,19 @@ def run(app):
                 dsz = min(th - 24, 64)
                 drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
                 pygame.draw.rect(screen, TILE, drect, border_radius=12)
+                # while this ✕ is being held, fill it from the bottom up as a hold-to-unpair progress meter
+                hp = hold_progress()
+                if hold is not None and hold["kind"] == "unpair" and hold["slot"] == slot and hp:
+                    fh = int(dsz * hp)
+                    fill = pygame.Rect(drect.x, drect.bottom - fh, dsz, fh)
+                    pygame.draw.rect(screen, STOP, fill, border_radius=12)
                 pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
-                x = f_big.render("✕", True, STOP)
+                x = f_big.render("✕", True, FG if (hold is not None and hold["kind"] == "unpair"
+                                                   and hold["slot"] == slot) else STOP)
                 screen.blit(x, (drect.centerx - x.get_width() // 2,
                                 drect.centery - x.get_height() // 2))
+                screen.blit(f_small.render("hold to remove", True, DIM),
+                            (drect.x - 6, drect.bottom + 2))
                 del_rects.append((drect, slot))
 
         # firmware log panel (the in-app serial monitor) — fills the area below the tiles
@@ -128,26 +153,67 @@ def run(app):
         if app.forwarding:
             bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
             pygame.draw.rect(screen, STOP, bar)
-            msg = f_big.render("FORWARDING — Deck controls detached · tap anywhere to stop", True, (255, 255, 255))
+            hp = hold_progress()
+            if hold is not None and hold["kind"] == "stop" and hp:
+                # fill the banner left->right as the stop hold progresses; lighten so the fill reads as a meter
+                pygame.draw.rect(screen, (255, 150, 150),
+                                 pygame.Rect(bar.x, bar.y, int(bar.width * hp), bar.height))
+                left = max(0.0, HOLD_SECS * (1.0 - hp))
+                msg = f_big.render("KEEP HOLDING TO STOP — %.0fs" % (left + 0.99),
+                                   True, (255, 255, 255))
+            else:
+                msg = f_big.render("FORWARDING — Deck controls detached · hold anywhere 5s to stop",
+                                   True, (255, 255, 255))
             screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
         pygame.display.flip()
 
-    def handle_tap(pos):
-        # Taps only ENQUEUE intents; the IO thread runs the actual grab()/send() so they never race the
-        # forwarding thread's serial/libusb access.
+    def handle_down(pos):
+        # Press handler. CONNECT (toggle a live tile) fires immediately -- it's cheap and reversible. The
+        # destructive actions (STOP a forward, REMOVE a bond) instead ARM a hold; they only fire from
+        # check_hold() once HOLD_SECS elapses, so an errant tap can't trigger them. Actions still only
+        # ENQUEUE intents so they never race the IO thread's serial/libusb access.
+        nonlocal hold
+        if hold is not None:
+            return  # a hold is already in progress; ignore re-entrant/duplicate down events
         if app.forwarding:
-            app.request_stop()
+            # hold anywhere on the screen to stop forwarding
+            hold = {"kind": "stop", "slot": None, "start": time.monotonic()}
             return
-        # the remove-bond ✕ takes priority over the tile body it sits on
+        # the remove-bond ✕ takes priority over the tile body it sits on -- hold it to unpair
         for drect, slot in del_rects:
             if drect.collidepoint(pos):
-                app.request_remove(slot)
+                hold = {"kind": "unpair", "slot": slot, "start": time.monotonic()}
                 return
+        # live tile: connecting/forwarding stays a single tap
         for rect, slot, tappable in tile_rects:
             if rect.collidepoint(pos) and tappable:
                 app.request_toggle(slot)
                 return
 
+    def handle_up():
+        # Lifting (or a stray up) before HOLD_SECS cancels the pending destructive action.
+        nonlocal hold
+        hold = None
+
+    def check_hold():
+        # Fire the held action once it has been sustained long enough, then disarm. Also cancel a hold whose
+        # target went away (the forward auto-released, or forwarding started so a bond can't be removed).
+        nonlocal hold
+        if hold is None:
+            return
+        if hold["kind"] == "stop" and not app.forwarding:
+            hold = None
+            return
+        if hold["kind"] == "unpair" and app.forwarding:
+            hold = None
+            return
+        if time.monotonic() - hold["start"] >= HOLD_SECS:
+            if hold["kind"] == "stop":
+                app.request_stop()
+            elif hold["kind"] == "unpair":
+                app.request_remove(hold["slot"])
+            hold = None
+
     running = True
     while running:
         for ev in pygame.event.get():
@@ -156,9 +222,13 @@ def run(app):
             elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
                 running = False
             elif ev.type == pygame.MOUSEBUTTONDOWN:
-                handle_tap(ev.pos)
+                handle_down(ev.pos)
             elif ev.type == pygame.FINGERDOWN:
-                handle_tap((int(ev.x * W), int(ev.y * H)))
+                handle_down((int(ev.x * W), int(ev.y * H)))
+            elif ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
+                handle_up()
+
+        check_hold()  # promote a sustained hold into the actual stop/unpair intent
 
         # Render only -- serial I/O + input forwarding run on app._io_loop (a separate thread) so the
         # vsync'd flip() below can't throttle forwarding to 60Hz. A bad draw must never kill the app.