openctrl.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. #!/usr/bin/env python3
  2. """openctrl.py -- Steam Deck forwarder for the ReversePuck emulated Steam Controller 2.
  3. Runs on the Steam Deck (Desktop Mode). Opens the nRF (Valve 28DE:1302) CDC port, reads its STATUS
  4. stream (which bonded pucks are live), and shows a fullscreen touchscreen UI of tappable puck tiles.
  5. Tap an *available* puck -> the Deck's controls are grabbed (detached) and streamed to the nRF, which
  6. relays them over RF so the puck presents a Steam Controller 2 to its host. Tap again, or lose the RF
  7. link, and control returns to the Deck.
  8. python3 openctrl.py # fullscreen touchscreen UI
  9. python3 openctrl.py --debug # headless: print status, no UI (for bring-up)
  10. python3 openctrl.py --port /dev/ttyACM0 # override CDC port autodetect
  11. python3 openctrl.py --evdev # use the evdev fallback input source (lower fidelity)
  12. The dongle (nRF CDC) is hot-pluggable: launch with or without it, it appears/disappears live.
  13. Deps: pyserial + pygame (UI) + pyusb (input/USB detach). See README.md / the ./ReversePuck launcher.
  14. """
  15. import argparse
  16. import os
  17. import sys
  18. import threading
  19. import time
  20. from collections import deque
  21. import frame
  22. import input_source as isrc
  23. VID = 0x28DE
  24. PID = 0x1302 # the ReversePuck emulated controller (nRF "dongle")
  25. PUCK_PID = 0x1304 # a puck's own CDC -- never grab this as the dongle
  26. INPUT_HZ = 250.0
  27. def find_port():
  28. try:
  29. from serial.tools import list_ports
  30. except Exception:
  31. return None
  32. for p in list_ports.comports():
  33. if (p.vid, p.pid) == (VID, PID):
  34. return p.device
  35. # fall back: any Valve CDC except a puck's (so a puck docked on the Deck isn't mistaken for it)
  36. for p in list_ports.comports():
  37. if p.vid == VID and p.pid != PUCK_PID:
  38. return p.device
  39. return None
  40. class App:
  41. def __init__(self, args):
  42. self.args = args
  43. import serial # imported here so --help works without pyserial
  44. self._serial = serial
  45. # The dongle (nRF CDC) is HOT-PLUGGABLE: we don't require it at launch and we re-open it
  46. # whenever it (re)appears, and tear down cleanly when it goes away.
  47. self.ser = None
  48. self.port_name = None
  49. self.reader = frame.Reader()
  50. self._last_open_try = 0.0
  51. self.input = None
  52. self.forwarding = False
  53. self.fwd_slot = None
  54. self.last_input = 0.0
  55. # serial I/O + input forwarding run on a DEDICATED thread (see _io_loop): pygame's vsync'd
  56. # flip() paces the UI render loop at the Deck's 60Hz, which was capping forwarding to 60Hz.
  57. # The UI thread only draws + enqueues tap intents here; this thread owns self.ser/self.input.
  58. self._cmdq = deque()
  59. self._io_stop = threading.Event()
  60. self._io_thread = None
  61. # model surfaced to the UI
  62. self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
  63. "bonds": [], "forwarding": False}
  64. self.note = "waiting for ReversePuck dongle (28DE:1302)…"
  65. self.log = [] # firmware '#' diagnostic lines (the in-app serial monitor)
  66. # file log: everything (firmware '#' lines + app events) with timestamps, for sharing.
  67. self.logf = None
  68. try:
  69. self.logf = open(args.logfile, "a", buffering=1) # line-buffered
  70. self._flog("==== openctrl start ====")
  71. except Exception as ex:
  72. print("logfile open failed (%s); continuing without it" % ex)
  73. def _flog(self, line):
  74. if self.logf:
  75. try:
  76. self.logf.write("%.3f %s\n" % (time.monotonic(), line))
  77. except Exception:
  78. pass
  79. @property
  80. def dongle_connected(self):
  81. return self.ser is not None
  82. # ---- serial I/O (hot-plug aware) ----
  83. def _open_serial(self):
  84. """Try to (re)open the dongle CDC port. Throttled to ~2 Hz. Sets self.ser on success."""
  85. if self.ser is not None:
  86. return
  87. now = time.monotonic()
  88. if now - self._last_open_try < 0.5:
  89. return
  90. self._last_open_try = now
  91. port = self.args.port or find_port()
  92. if not port:
  93. return
  94. try:
  95. self.ser = self._serial.Serial(port, 115200, timeout=0)
  96. self.port_name = port
  97. self.reader = frame.Reader() # fresh framer for the new connection
  98. # forget any stale link state from a previous dongle session
  99. self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
  100. "bonds": [], "forwarding": False}
  101. self.note = "dongle connected: %s" % port
  102. self._flog("dongle connected: %s" % port)
  103. except Exception:
  104. self.ser = None # e.g. permission / busy; will retry
  105. def _drop_serial(self, why):
  106. """Tear down on disconnect: close the port FIRST so we never try to write to a gone dongle, then
  107. release the grabbed pad locally and reset the model. (Closing first also makes send() a no-op, so
  108. there's no re-entrant _drop_serial via a failing write.)"""
  109. try:
  110. if self.ser:
  111. self.ser.close()
  112. except Exception:
  113. pass
  114. self.ser = None
  115. self.port_name = None
  116. self._release_local() # hand the pad back without trying to notify the dongle that's gone
  117. self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
  118. "bonds": [], "forwarding": False}
  119. self.note = "dongle disconnected (%s) — waiting…" % why
  120. self._flog("dongle disconnected: %s" % why)
  121. def pump_serial(self):
  122. if self.ser is None:
  123. self._open_serial()
  124. return
  125. # A yanked CDC often makes pyserial return b'' silently rather than raise, so the port node
  126. # disappearing is the reliable disconnect signal. Check it (throttled).
  127. now = time.monotonic()
  128. if now - self._last_open_try >= 0.5:
  129. self._last_open_try = now
  130. if self.port_name and not os.path.exists(self.port_name):
  131. self._drop_serial("unplugged")
  132. return
  133. try:
  134. data = self.ser.read(512)
  135. except Exception as ex:
  136. # device yanked / I/O error -> clean teardown, then we'll re-detect it
  137. self._drop_serial(str(ex) or "read error")
  138. return
  139. last_haptic = None
  140. for typ, payload in self.reader.feed(data):
  141. if typ == frame.T_STATUS:
  142. st = frame.parse_status(payload)
  143. if st:
  144. self.status = st
  145. elif typ == frame.T_HAPTIC:
  146. # coalesce a burst to the newest state -- replaying every frame is what lagged the rumble
  147. last_haptic = frame.parse_haptic(payload)
  148. elif typ == frame.T_TEXT:
  149. line = payload.decode("latin1", "replace").rstrip()
  150. if line:
  151. self.note = line
  152. self.log.append(line)
  153. self.log = self.log[-300:]
  154. self._flog(line)
  155. if self.args.debug:
  156. print(line) # stream the firmware log like a serial monitor
  157. # play only the newest haptic of this batch, and only while we're actually forwarding
  158. if last_haptic and self.forwarding and self.input:
  159. try:
  160. self.input.rumble(last_haptic["intensity"], last_haptic["left"],
  161. last_haptic["right"], last_haptic["lgain"],
  162. last_haptic["rgain"])
  163. except Exception:
  164. pass
  165. def send(self, b):
  166. if self.ser is None:
  167. return
  168. try:
  169. self.ser.write(b)
  170. except Exception:
  171. self._drop_serial("write error")
  172. # ---- forwarding control ----
  173. def _ensure_input(self):
  174. if self.input is None:
  175. # libusb DeckSource by default (detaches the pad from Steam + decodes the raw report
  176. # correctly); --evdev forces the lower-fidelity fallback.
  177. self.input = isrc.open_source(prefer_evdev=self.args.evdev,
  178. path=self.args.input)
  179. return self.input
  180. def start_forwarding(self, slot):
  181. try:
  182. self._ensure_input().grab()
  183. except Exception as ex:
  184. # roll back any partial interface claim so the pad is never left half-detached, and force a
  185. # clean rebuild of the source next time (the cached handle may be wedged).
  186. self._drop_input("grab failed: %s" % ex)
  187. self.note = "grab failed: %s" % ex
  188. return
  189. self.forwarding = True
  190. self.fwd_slot = slot
  191. self.send(frame.build_set_forwarding(True))
  192. self.note = "forwarding -> slot %s (%s)" % (slot, self.input.name)
  193. self._flog("APP forwarding START slot=%s input=%s" % (slot, self.input.name))
  194. def _release_local(self):
  195. """Clear forwarding state + ungrab the pad locally, WITHOUT notifying the dongle (used when the
  196. dongle or input device has gone away, so there's nothing to tell)."""
  197. self.forwarding = False
  198. self.fwd_slot = None
  199. if self.input:
  200. try:
  201. self.input.ungrab()
  202. except Exception:
  203. pass
  204. def stop_forwarding(self):
  205. """Clean, user-initiated stop: tell the firmware to stop forwarding, then release the pad."""
  206. if self.forwarding:
  207. self.send(frame.build_set_forwarding(False))
  208. self._release_local()
  209. self.note = "control returned to Deck"
  210. self._flog("APP forwarding STOP")
  211. def _drop_input(self, why):
  212. """Tear down the input source and force a rebuild on the next grab, so a transient USB hiccup on
  213. the Deck pad (or a failed grab) can't wedge the app behind a dead handle."""
  214. self._release_local()
  215. self.input = None
  216. self._flog("input dropped: %s" % why)
  217. def toggle(self, slot):
  218. """Tap handler: a tappable tile is one whose puck is live (alive)."""
  219. if self.forwarding:
  220. self.stop_forwarding()
  221. elif self.dongle_connected:
  222. self.start_forwarding(slot)
  223. def remove_bond(self, slot):
  224. """GUI "remove": un-bond a puck slot on the firmware. Refused while forwarding to it."""
  225. if self.forwarding and self.fwd_slot == slot:
  226. return
  227. self.send(frame.build_clear_bond(slot))
  228. # drop it from the local model immediately so the tile disappears without waiting for STATUS
  229. self.status["bonds"] = [b for b in self.status.get("bonds", []) if b["slot"] != slot]
  230. self._flog("APP remove bond slot=%s" % slot)
  231. def pump_input(self):
  232. if not self.forwarding or not self.input:
  233. return
  234. try:
  235. st = self.input.pump()
  236. except Exception as ex:
  237. # the pad raised (USB reset / yanked) mid-forward. Notify the firmware to stop (the dongle is
  238. # likely still up, so the puck doesn't freeze on the last frame), then drop the dead handle so
  239. # the next tap rebuilds it -- never let this bubble up and kill the loop.
  240. self._flog("input pump error: %s" % ex)
  241. self.stop_forwarding()
  242. self.input = None
  243. self.note = "input device error — control returned to Deck"
  244. return
  245. now = time.monotonic()
  246. if now - self.last_input >= 1.0 / INPUT_HZ:
  247. self.last_input = now
  248. self.send(frame.build_input(st))
  249. def auto_release_on_drop(self):
  250. """If the RF link drops while forwarding, hand control back to the Deck."""
  251. if self.forwarding and not self.status.get("link_up"):
  252. # only release if the slot we're forwarding to is no longer alive
  253. slot = self.fwd_slot
  254. bonds = self.status.get("bonds", [])
  255. alive = any(b["slot"] == slot and b["alive"] for b in bonds)
  256. if not alive:
  257. self.stop_forwarding()
  258. # ---- UI -> IO thread intents (UI thread enqueues; _io_loop executes, so grab()/send() never run
  259. # concurrently with the forwarding thread's serial/libusb access) ----
  260. def request_toggle(self, slot):
  261. self._cmdq.append(("toggle", slot))
  262. def request_stop(self):
  263. self._cmdq.append(("stop", None))
  264. def request_remove(self, slot):
  265. self._cmdq.append(("remove", slot))
  266. def _io_loop(self):
  267. """Dedicated forwarding/serial thread, decoupled from the 60Hz UI render loop so input streams at
  268. the full INPUT_HZ. SOLE owner of self.ser + self.input: the UI thread only enqueues intents."""
  269. while not self._io_stop.is_set():
  270. while self._cmdq:
  271. try:
  272. kind, slot = self._cmdq.popleft()
  273. except IndexError:
  274. break
  275. try:
  276. if kind == "toggle":
  277. self.toggle(slot)
  278. elif kind == "stop":
  279. self.stop_forwarding()
  280. elif kind == "remove":
  281. self.remove_bond(slot)
  282. except Exception as ex:
  283. self._flog("cmd %s error: %r" % (kind, ex))
  284. try:
  285. self.pump_serial()
  286. self.pump_input()
  287. self.auto_release_on_drop()
  288. except Exception as ex:
  289. self._flog("io loop error: %r" % ex)
  290. # short sleep: pump_input self-throttles sends to INPUT_HZ, this just yields the CPU
  291. time.sleep(0.001)
  292. # ---- loops ----
  293. def run_debug(self):
  294. last = 0
  295. while True:
  296. try:
  297. self.pump_serial()
  298. self.pump_input()
  299. self.auto_release_on_drop()
  300. except Exception as ex:
  301. self._flog("debug loop error: %r" % ex)
  302. if time.monotonic() - last > 0.5:
  303. last = time.monotonic()
  304. s = self.status
  305. tiles = ", ".join(
  306. "[%d %s%s]" % (b["slot"], b["serial"] or "?", " LIVE" if b["alive"] else "")
  307. for b in s["bonds"]) or "(no bonds)"
  308. print("dongle=%s link=%s fwd=%s ch=%s %s | %s" %
  309. ("yes" if self.dongle_connected else "NO", s["link_up"],
  310. self.forwarding, s["sess_ch"], tiles, self.note))
  311. time.sleep(0.002)
  312. def run_ui(self):
  313. import ui
  314. self._io_stop.clear()
  315. self._io_thread = threading.Thread(target=self._io_loop, daemon=True)
  316. self._io_thread.start()
  317. try:
  318. ui.run(self)
  319. finally:
  320. self._io_stop.set()
  321. self._io_thread.join(timeout=1.0)
  322. def main():
  323. ap = argparse.ArgumentParser(description="Steam Deck forwarder for ReversePuck")
  324. ap.add_argument("--port", help="CDC port (default: autodetect Valve 28DE:1302)")
  325. ap.add_argument("--input", help="force an evdev node, e.g. /dev/input/event12 (only with --evdev)")
  326. ap.add_argument("--evdev", action="store_true",
  327. help="use the evdev fallback instead of the libusb DeckSource (lower fidelity, "
  328. "no USB-level detach)")
  329. ap.add_argument("--debug", action="store_true", help="headless status print, no UI")
  330. ap.add_argument("--logfile", default="openctrl.log",
  331. help="append firmware log + app events here (default: openctrl.log in this dir)")
  332. args = ap.parse_args()
  333. app = App(args)
  334. try:
  335. if args.debug:
  336. app.run_debug()
  337. else:
  338. app.run_ui()
  339. except KeyboardInterrupt:
  340. pass
  341. finally:
  342. if app.forwarding:
  343. app.stop_forwarding()
  344. # belt-and-suspenders: never exit with the Deck pad still detached from Steam
  345. if app.input:
  346. try:
  347. app.input.ungrab()
  348. except Exception:
  349. pass
  350. if __name__ == "__main__":
  351. main()