openctrl.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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 --hidraw # also read gyro/trackpads from the Deck hidraw (see input_source)
  12. Deps: pyserial (required), evdev (for input), pygame (for the UI). See README.md.
  13. """
  14. import argparse
  15. import sys
  16. import time
  17. import frame
  18. import input_source as isrc
  19. VID = 0x28DE
  20. PID = 0x1302 # the OpenController emulated controller
  21. INPUT_HZ = 250.0
  22. def find_port():
  23. try:
  24. from serial.tools import list_ports
  25. except Exception:
  26. return None
  27. for p in list_ports.comports():
  28. if (p.vid, p.pid) == (VID, PID):
  29. return p.device
  30. # fall back: any Valve CDC
  31. for p in list_ports.comports():
  32. if p.vid == VID:
  33. return p.device
  34. return None
  35. class App:
  36. def __init__(self, args):
  37. self.args = args
  38. import serial # imported here so --help works without pyserial
  39. port = args.port or find_port()
  40. if not port:
  41. raise SystemExit("OpenController CDC port not found (Valve 28DE:1302). "
  42. "Plug in the nRF, or pass --port.")
  43. self.port_name = port
  44. self.ser = serial.Serial(port, 115200, timeout=0)
  45. self.reader = frame.Reader()
  46. self.input = None
  47. self.hidraw = None
  48. self.forwarding = False
  49. self.fwd_slot = None
  50. self.last_input = 0.0
  51. # model surfaced to the UI
  52. self.status = {"link_up": False, "link_slot": None, "sess_ch": 0,
  53. "bonds": [], "forwarding": False}
  54. self.note = "connected to %s" % port
  55. self.log = [] # firmware '#' diagnostic lines (the in-app serial monitor)
  56. # ---- serial I/O ----
  57. def pump_serial(self):
  58. try:
  59. data = self.ser.read(512)
  60. except Exception:
  61. data = b""
  62. for typ, payload in self.reader.feed(data):
  63. if typ == frame.T_STATUS:
  64. st = frame.parse_status(payload)
  65. if st:
  66. self.status = st
  67. elif typ == frame.T_TEXT:
  68. line = payload.decode("latin1", "replace").rstrip()
  69. if line:
  70. self.note = line
  71. self.log.append(line)
  72. self.log = self.log[-300:]
  73. if self.args.debug:
  74. print(line) # stream the firmware log like a serial monitor
  75. def send(self, b):
  76. try:
  77. self.ser.write(b)
  78. except Exception:
  79. pass
  80. # ---- forwarding control ----
  81. def _ensure_input(self):
  82. if self.input is None:
  83. self.input = isrc.EvdevSource(path=self.args.input)
  84. if self.args.hidraw:
  85. try:
  86. self.hidraw = isrc.HidrawImu()
  87. except Exception:
  88. self.hidraw = None
  89. return self.input
  90. def start_forwarding(self, slot):
  91. try:
  92. self._ensure_input().grab()
  93. except Exception as ex:
  94. self.note = "grab failed: %s" % ex
  95. return
  96. self.forwarding = True
  97. self.fwd_slot = slot
  98. self.send(frame.build_set_forwarding(True))
  99. self.note = "forwarding -> slot %s (%s)" % (slot, self.input.name)
  100. def stop_forwarding(self):
  101. self.forwarding = False
  102. self.fwd_slot = None
  103. self.send(frame.build_set_forwarding(False))
  104. if self.input:
  105. self.input.ungrab()
  106. self.note = "control returned to Deck"
  107. def toggle(self, slot):
  108. """Tap handler: a tappable tile is one whose puck is live (alive)."""
  109. if self.forwarding:
  110. self.stop_forwarding()
  111. else:
  112. self.start_forwarding(slot)
  113. def pump_input(self):
  114. if not self.forwarding or not self.input:
  115. return
  116. st = self.input.pump()
  117. if self.hidraw:
  118. self.hidraw.read_into(st)
  119. now = time.monotonic()
  120. if now - self.last_input >= 1.0 / INPUT_HZ:
  121. self.last_input = now
  122. self.send(frame.build_input(st))
  123. def auto_release_on_drop(self):
  124. """If the RF link drops while forwarding, hand control back to the Deck."""
  125. if self.forwarding and not self.status.get("link_up"):
  126. # only release if the slot we're forwarding to is no longer alive
  127. slot = self.fwd_slot
  128. bonds = self.status.get("bonds", [])
  129. alive = slot is not None and slot < len(bonds) and bonds[slot]["alive"]
  130. if not alive:
  131. self.stop_forwarding()
  132. # ---- loops ----
  133. def run_debug(self):
  134. last = 0
  135. while True:
  136. self.pump_serial()
  137. self.pump_input()
  138. self.auto_release_on_drop()
  139. if time.monotonic() - last > 0.5:
  140. last = time.monotonic()
  141. s = self.status
  142. tiles = ", ".join(
  143. "[%d %s%s]" % (i, b["serial"] or "?", " LIVE" if b["alive"] else "")
  144. for i, b in enumerate(s["bonds"]) if b["used"]) or "(no bonds)"
  145. print("link=%s fwd=%s ch=%s %s | %s" %
  146. (s["link_up"], self.forwarding, s["sess_ch"], tiles, self.note))
  147. time.sleep(0.002)
  148. def run_ui(self):
  149. import ui
  150. ui.run(self)
  151. def main():
  152. ap = argparse.ArgumentParser(description="Steam Deck forwarder for OpenController")
  153. ap.add_argument("--port", help="CDC port (default: autodetect Valve 28DE:1302)")
  154. ap.add_argument("--input", help="force the Deck controller evdev node, e.g. /dev/input/event12 "
  155. "(default: prefer the physical 28de:1205 controller)")
  156. ap.add_argument("--debug", action="store_true", help="headless status print, no UI")
  157. ap.add_argument("--hidraw", action="store_true", help="also read gyro/trackpads via hid-steam hidraw")
  158. args = ap.parse_args()
  159. app = App(args)
  160. try:
  161. if args.debug:
  162. app.run_debug()
  163. else:
  164. app.run_ui()
  165. except KeyboardInterrupt:
  166. pass
  167. finally:
  168. if app.forwarding:
  169. app.stop_forwarding()
  170. if __name__ == "__main__":
  171. main()