Explorar o código

Robustness fixes

Jari hai 4 meses
pai
achega
b2d3b73aae
Modificáronse 2 ficheiros con 64 adicións e 12 borrados
  1. 55 1
      ReversePuck/openctrl.py
  2. 9 11
      ReversePuck/ui.py

+ 55 - 1
ReversePuck/openctrl.py

@@ -18,7 +18,9 @@ Deps: pyserial + pygame (UI) + pyusb (input/USB detach). See README.md / the ./R
 import argparse
 import os
 import sys
+import threading
 import time
+from collections import deque
 
 import frame
 import input_source as isrc
@@ -62,6 +64,13 @@ class App:
         self.fwd_slot = None
         self.last_input = 0.0
 
+        # serial I/O + input forwarding run on a DEDICATED thread (see _io_loop): pygame's vsync'd
+        # flip() paces the UI render loop at the Deck's 60Hz, which was capping forwarding to 60Hz.
+        # The UI thread only draws + enqueues tap intents here; this thread owns self.ser/self.input.
+        self._cmdq = deque()
+        self._io_stop = threading.Event()
+        self._io_thread = None
+
         # model surfaced to the UI
         self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
                        "bonds": [], "forwarding": False}
@@ -276,6 +285,44 @@ class App:
             if not alive:
                 self.stop_forwarding()
 
+    # ---- UI -> IO thread intents (UI thread enqueues; _io_loop executes, so grab()/send() never run
+    #      concurrently with the forwarding thread's serial/libusb access) ----
+    def request_toggle(self, slot):
+        self._cmdq.append(("toggle", slot))
+
+    def request_stop(self):
+        self._cmdq.append(("stop", None))
+
+    def request_remove(self, slot):
+        self._cmdq.append(("remove", slot))
+
+    def _io_loop(self):
+        """Dedicated forwarding/serial thread, decoupled from the 60Hz UI render loop so input streams at
+        the full INPUT_HZ. SOLE owner of self.ser + self.input: the UI thread only enqueues intents."""
+        while not self._io_stop.is_set():
+            while self._cmdq:
+                try:
+                    kind, slot = self._cmdq.popleft()
+                except IndexError:
+                    break
+                try:
+                    if kind == "toggle":
+                        self.toggle(slot)
+                    elif kind == "stop":
+                        self.stop_forwarding()
+                    elif kind == "remove":
+                        self.remove_bond(slot)
+                except Exception as ex:
+                    self._flog("cmd %s error: %r" % (kind, ex))
+            try:
+                self.pump_serial()
+                self.pump_input()
+                self.auto_release_on_drop()
+            except Exception as ex:
+                self._flog("io loop error: %r" % ex)
+            # short sleep: pump_input self-throttles sends to INPUT_HZ, this just yields the CPU
+            time.sleep(0.001)
+
     # ---- loops ----
     def run_debug(self):
         last = 0
@@ -299,7 +346,14 @@ class App:
 
     def run_ui(self):
         import ui
-        ui.run(self)
+        self._io_stop.clear()
+        self._io_thread = threading.Thread(target=self._io_loop, daemon=True)
+        self._io_thread.start()
+        try:
+            ui.run(self)
+        finally:
+            self._io_stop.set()
+            self._io_thread.join(timeout=1.0)
 
 
 def main():

+ 9 - 11
ReversePuck/ui.py

@@ -133,17 +133,19 @@ def run(app):
         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.
         if app.forwarding:
-            app.stop_forwarding()
+            app.request_stop()
             return
         # the remove-bond ✕ takes priority over the tile body it sits on
         for drect, slot in del_rects:
             if drect.collidepoint(pos):
-                app.remove_bond(slot)
+                app.request_remove(slot)
                 return
         for rect, slot, tappable in tile_rects:
             if rect.collidepoint(pos) and tappable:
-                app.toggle(slot)
+                app.request_toggle(slot)
                 return
 
     running = True
@@ -158,17 +160,13 @@ def run(app):
             elif ev.type == pygame.FINGERDOWN:
                 handle_tap((int(ev.x * W), int(ev.y * H)))
 
-        # One bad frame (a transient serial/USB/draw error) must never take the whole app down — log it
-        # and keep looping so a dongle/pad replug recovers on its own.
+        # 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.
         try:
-            app.pump_serial()
-            app.pump_input()
-            app.auto_release_on_drop()
             draw()
         except Exception as ex:
-            app._flog("UI loop error: %r" % ex)
+            app._flog("UI draw error: %r" % ex)
             app.note = "recovered from error: %s" % ex
-        # high tick so forwarding input stays smooth; UI redraw is cheap
-        clock.tick(120)
+        clock.tick(60)  # UI render rate; forwarding is decoupled on the IO thread now
 
     pygame.quit()