openctrl.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. #!/usr/bin/env python3
  2. """openctrl.py -- Steam Deck forwarder for the OpenController 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 / run.sh.
  14. """
  15. import argparse
  16. import os
  17. import sys
  18. import time
  19. import frame
  20. import input_source as isrc
  21. VID = 0x28DE
  22. PID = 0x1302 # the OpenController emulated controller
  23. INPUT_HZ = 250.0
  24. def find_port():
  25. try:
  26. from serial.tools import list_ports
  27. except Exception:
  28. return None
  29. for p in list_ports.comports():
  30. if (p.vid, p.pid) == (VID, PID):
  31. return p.device
  32. # fall back: any Valve CDC
  33. for p in list_ports.comports():
  34. if p.vid == VID:
  35. return p.device
  36. return None
  37. class App:
  38. def __init__(self, args):
  39. self.args = args
  40. import serial # imported here so --help works without pyserial
  41. self._serial = serial
  42. # The dongle (nRF CDC) is HOT-PLUGGABLE: we don't require it at launch and we re-open it
  43. # whenever it (re)appears, and tear down cleanly when it goes away.
  44. self.ser = None
  45. self.port_name = None
  46. self.reader = frame.Reader()
  47. self._last_open_try = 0.0
  48. self.input = None
  49. self.forwarding = False
  50. self.fwd_slot = None
  51. self.last_input = 0.0
  52. # model surfaced to the UI
  53. self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
  54. "bonds": [], "forwarding": False}
  55. self.note = "waiting for OpenController dongle (28DE:1302)…"
  56. self.log = [] # firmware '#' diagnostic lines (the in-app serial monitor)
  57. # file log: everything (firmware '#' lines + app events) with timestamps, for sharing.
  58. self.logf = None
  59. try:
  60. self.logf = open(args.logfile, "a", buffering=1) # line-buffered
  61. self._flog("==== openctrl start ====")
  62. except Exception as ex:
  63. print("logfile open failed (%s); continuing without it" % ex)
  64. def _flog(self, line):
  65. if self.logf:
  66. try:
  67. self.logf.write("%.3f %s\n" % (time.monotonic(), line))
  68. except Exception:
  69. pass
  70. @property
  71. def dongle_connected(self):
  72. return self.ser is not None
  73. # ---- serial I/O (hot-plug aware) ----
  74. def _open_serial(self):
  75. """Try to (re)open the dongle CDC port. Throttled to ~2 Hz. Sets self.ser on success."""
  76. if self.ser is not None:
  77. return
  78. now = time.monotonic()
  79. if now - self._last_open_try < 0.5:
  80. return
  81. self._last_open_try = now
  82. port = self.args.port or find_port()
  83. if not port:
  84. return
  85. try:
  86. self.ser = self._serial.Serial(port, 115200, timeout=0)
  87. self.port_name = port
  88. self.reader = frame.Reader() # fresh framer for the new connection
  89. # forget any stale link state from a previous dongle session
  90. self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
  91. "bonds": [], "forwarding": False}
  92. self.note = "dongle connected: %s" % port
  93. self._flog("dongle connected: %s" % port)
  94. except Exception:
  95. self.ser = None # e.g. permission / busy; will retry
  96. def _drop_serial(self, why):
  97. """Tear down on disconnect: stop forwarding, close the port, reset the model."""
  98. if self.forwarding:
  99. self.stop_forwarding()
  100. try:
  101. if self.ser:
  102. self.ser.close()
  103. except Exception:
  104. pass
  105. self.ser = None
  106. self.port_name = None
  107. self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
  108. "bonds": [], "forwarding": False}
  109. self.note = "dongle disconnected (%s) — waiting…" % why
  110. self._flog("dongle disconnected: %s" % why)
  111. def pump_serial(self):
  112. if self.ser is None:
  113. self._open_serial()
  114. return
  115. # A yanked CDC often makes pyserial return b'' silently rather than raise, so the port node
  116. # disappearing is the reliable disconnect signal. Check it (throttled).
  117. now = time.monotonic()
  118. if now - self._last_open_try >= 0.5:
  119. self._last_open_try = now
  120. if self.port_name and not os.path.exists(self.port_name):
  121. self._drop_serial("unplugged")
  122. return
  123. try:
  124. data = self.ser.read(512)
  125. except Exception as ex:
  126. # device yanked / I/O error -> clean teardown, then we'll re-detect it
  127. self._drop_serial(str(ex) or "read error")
  128. return
  129. last_haptic = None
  130. for typ, payload in self.reader.feed(data):
  131. if typ == frame.T_STATUS:
  132. st = frame.parse_status(payload)
  133. if st:
  134. self.status = st
  135. elif typ == frame.T_HAPTIC:
  136. # coalesce a burst to the newest state -- replaying every frame is what lagged the rumble
  137. last_haptic = frame.parse_haptic(payload)
  138. elif typ == frame.T_TEXT:
  139. line = payload.decode("latin1", "replace").rstrip()
  140. if line:
  141. self.note = line
  142. self.log.append(line)
  143. self.log = self.log[-300:]
  144. self._flog(line)
  145. if self.args.debug:
  146. print(line) # stream the firmware log like a serial monitor
  147. # play only the newest haptic of this batch, and only while we're actually forwarding
  148. if last_haptic and self.forwarding and self.input:
  149. try:
  150. self.input.rumble(last_haptic["intensity"], last_haptic["left"],
  151. last_haptic["right"], last_haptic["lgain"],
  152. last_haptic["rgain"])
  153. except Exception:
  154. pass
  155. def send(self, b):
  156. if self.ser is None:
  157. return
  158. try:
  159. self.ser.write(b)
  160. except Exception:
  161. self._drop_serial("write error")
  162. # ---- forwarding control ----
  163. def _ensure_input(self):
  164. if self.input is None:
  165. # libusb DeckSource by default (detaches the pad from Steam + decodes the raw report
  166. # correctly); --evdev forces the lower-fidelity fallback.
  167. self.input = isrc.open_source(prefer_evdev=self.args.evdev,
  168. path=self.args.input)
  169. return self.input
  170. def start_forwarding(self, slot):
  171. try:
  172. self._ensure_input().grab()
  173. except Exception as ex:
  174. self.note = "grab failed: %s" % ex
  175. return
  176. self.forwarding = True
  177. self.fwd_slot = slot
  178. self.send(frame.build_set_forwarding(True))
  179. self.note = "forwarding -> slot %s (%s)" % (slot, self.input.name)
  180. self._flog("APP forwarding START slot=%s input=%s" % (slot, self.input.name))
  181. def stop_forwarding(self):
  182. self.forwarding = False
  183. self.fwd_slot = None
  184. self.send(frame.build_set_forwarding(False))
  185. if self.input:
  186. self.input.ungrab()
  187. self.note = "control returned to Deck"
  188. self._flog("APP forwarding STOP")
  189. def toggle(self, slot):
  190. """Tap handler: a tappable tile is one whose puck is live (alive)."""
  191. if self.forwarding:
  192. self.stop_forwarding()
  193. elif self.dongle_connected:
  194. self.start_forwarding(slot)
  195. def remove_bond(self, slot):
  196. """GUI "remove": un-bond a puck slot on the firmware. Refused while forwarding to it."""
  197. if self.forwarding and self.fwd_slot == slot:
  198. return
  199. self.send(frame.build_clear_bond(slot))
  200. # drop it from the local model immediately so the tile disappears without waiting for STATUS
  201. self.status["bonds"] = [b for b in self.status.get("bonds", []) if b["slot"] != slot]
  202. self._flog("APP remove bond slot=%s" % slot)
  203. def pump_input(self):
  204. if not self.forwarding or not self.input:
  205. return
  206. st = self.input.pump()
  207. now = time.monotonic()
  208. if now - self.last_input >= 1.0 / INPUT_HZ:
  209. self.last_input = now
  210. self.send(frame.build_input(st))
  211. def auto_release_on_drop(self):
  212. """If the RF link drops while forwarding, hand control back to the Deck."""
  213. if self.forwarding and not self.status.get("link_up"):
  214. # only release if the slot we're forwarding to is no longer alive
  215. slot = self.fwd_slot
  216. bonds = self.status.get("bonds", [])
  217. alive = any(b["slot"] == slot and b["alive"] for b in bonds)
  218. if not alive:
  219. self.stop_forwarding()
  220. # ---- loops ----
  221. def run_debug(self):
  222. last = 0
  223. while True:
  224. self.pump_serial()
  225. self.pump_input()
  226. self.auto_release_on_drop()
  227. if time.monotonic() - last > 0.5:
  228. last = time.monotonic()
  229. s = self.status
  230. tiles = ", ".join(
  231. "[%d %s%s]" % (b["slot"], b["serial"] or "?", " LIVE" if b["alive"] else "")
  232. for b in s["bonds"]) or "(no bonds)"
  233. print("dongle=%s link=%s fwd=%s ch=%s %s | %s" %
  234. ("yes" if self.dongle_connected else "NO", s["link_up"],
  235. self.forwarding, s["sess_ch"], tiles, self.note))
  236. time.sleep(0.002)
  237. def run_ui(self):
  238. import ui
  239. ui.run(self)
  240. def main():
  241. ap = argparse.ArgumentParser(description="Steam Deck forwarder for OpenController")
  242. ap.add_argument("--port", help="CDC port (default: autodetect Valve 28DE:1302)")
  243. ap.add_argument("--input", help="force an evdev node, e.g. /dev/input/event12 (only with --evdev)")
  244. ap.add_argument("--evdev", action="store_true",
  245. help="use the evdev fallback instead of the libusb DeckSource (lower fidelity, "
  246. "no USB-level detach)")
  247. ap.add_argument("--debug", action="store_true", help="headless status print, no UI")
  248. ap.add_argument("--logfile", default="openctrl.log",
  249. help="append firmware log + app events here (default: openctrl.log in this dir)")
  250. args = ap.parse_args()
  251. app = App(args)
  252. try:
  253. if args.debug:
  254. app.run_debug()
  255. else:
  256. app.run_ui()
  257. except KeyboardInterrupt:
  258. pass
  259. finally:
  260. if app.forwarding:
  261. app.stop_forwarding()
  262. if __name__ == "__main__":
  263. main()