frame.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. """frame.py -- the USB-CDC wire protocol shared with ReversePuckFirmware/deck_input.cpp.
  2. frame = [0xA5][0x5A][type][len][payload(len)][sum8(type,len,payload...)]
  3. The reader resynchronizes on the 2-byte sync word, so any stray bytes (a boot banner, a serial
  4. monitor) are skipped harmlessly. Keep this file in lock-step with deck_input.{h,cpp}.
  5. """
  6. import struct
  7. SYNC0 = 0xA5
  8. SYNC1 = 0x5A
  9. # Deck -> nRF
  10. T_INPUT = 0x01
  11. T_CONTROL = 0x02
  12. T_REQ_STATUS = 0x03
  13. # nRF -> Deck
  14. T_STATUS = 0x10
  15. T_TEXT = 0x1F
  16. # nRF -> Deck haptic: [intensity u16][left u16][right u16][lgain u8][rgain u8] (Deck rumble cmd 0xEB)
  17. T_HAPTIC = 0x11
  18. # INPUT payload: buttons u32, lx ly rx ry s16, lt rt u8, lpx lpy rpx rpy s16, lpp rpp u16, axyz/gxyz s16.
  19. # 4 + 8 + 2 + 8 + 4 + 12 = 38 bytes, little-endian. Matches applyInput() in deck_input.cpp.
  20. _INPUT = struct.Struct("<I4hBB4hHH6h")
  21. INPUT_LEN = _INPUT.size # 38
  22. def _sum8(type_, payload):
  23. s = (type_ + len(payload)) & 0xFF
  24. for b in payload:
  25. s = (s + b) & 0xFF
  26. return s
  27. def build(type_, payload=b""):
  28. """Serialize one frame."""
  29. payload = bytes(payload)
  30. return bytes([SYNC0, SYNC1, type_, len(payload)]) + payload + bytes([_sum8(type_, payload)])
  31. def build_input(st):
  32. """st: an object/dict with Triton fields -> an INPUT frame."""
  33. g = st.get if isinstance(st, dict) else (lambda k, d=0: getattr(st, k, d))
  34. payload = _INPUT.pack(
  35. g("buttons", 0) & 0xFFFFFFFF,
  36. g("lx", 0), g("ly", 0), g("rx", 0), g("ry", 0),
  37. g("lt", 0) & 0xFF, g("rt", 0) & 0xFF,
  38. g("lpx", 0), g("lpy", 0), g("rpx", 0), g("rpy", 0),
  39. g("lpp", 0) & 0xFFFF, g("rpp", 0) & 0xFFFF,
  40. g("ax", 0), g("ay", 0), g("az", 0), g("gx", 0), g("gy", 0), g("gz", 0),
  41. )
  42. return build(T_INPUT, payload)
  43. def parse_haptic(payload):
  44. """Decode a T_HAPTIC payload -> dict for the Deck rumble cmd. Mirrors deck_input.cpp."""
  45. if len(payload) < 8:
  46. return None
  47. intensity, left, right = struct.unpack_from("<HHH", payload, 0)
  48. lgain, rgain = payload[6], payload[7]
  49. return {"intensity": intensity, "left": left, "right": right,
  50. "lgain": lgain, "rgain": rgain}
  51. def build_set_forwarding(on):
  52. return build(T_CONTROL, bytes([0x01, 1 if on else 0]))
  53. def build_clear_bond(slot):
  54. """Tell the firmware to un-bond (remove) slot `slot` -- the GUI "remove" affordance."""
  55. return build(T_CONTROL, bytes([0x02, slot & 0xFF]))
  56. def build_req_status():
  57. return build(T_REQ_STATUS)
  58. def parse_status(payload):
  59. """Decode a T_STATUS payload into a dict. Mirrors deckStatusTask() in deck_input.cpp.
  60. Only USED bonds are sent; each carries its firmware slot index (for removal)."""
  61. if len(payload) < 5:
  62. return None
  63. flags, link_slot, sess_ch, nbond_max, count = payload[:5]
  64. bonds = []
  65. off = 5
  66. for _ in range(count):
  67. if off + 26 > len(payload):
  68. break
  69. slot = payload[off]
  70. alive = payload[off + 1]
  71. puuid = struct.unpack_from("<I", payload, off + 2)[0]
  72. iuuid = struct.unpack_from("<I", payload, off + 6)[0]
  73. serial = payload[off + 10:off + 26].split(b"\x00")[0].decode("latin1", "replace")
  74. bonds.append({"slot": slot, "used": True, "alive": bool(alive),
  75. "puuid": puuid, "iuuid": iuuid, "serial": serial})
  76. off += 26
  77. return {
  78. "forwarding": bool(flags & 1),
  79. "link_up": bool(flags & 2),
  80. "link_slot": None if link_slot == 0xFF else link_slot,
  81. "sess_ch": sess_ch,
  82. "nbond_max": nbond_max,
  83. "bonds": bonds,
  84. }
  85. class Reader:
  86. """Feed raw bytes; yields (type, payload) tuples for each complete, checksum-valid frame.
  87. Bytes that fall *between* frames are the firmware's plain-text `#` diagnostic lines (Serial.printf,
  88. which the framed protocol resyncs past). We capture those too and surface them as (T_TEXT, line) so
  89. the app can show the firmware log without a serial monitor.
  90. """
  91. def __init__(self):
  92. self._st = 0
  93. self._type = 0
  94. self._len = 0
  95. self._buf = bytearray()
  96. self._sum = 0
  97. self._text = bytearray()
  98. def _flush_text(self, out):
  99. line = self._text.rstrip(b"\r\n")
  100. self._text = bytearray()
  101. if line and all(9 <= b < 127 or b in (10, 13) for b in line):
  102. out.append((T_TEXT, bytes(line)))
  103. def feed(self, data):
  104. out = []
  105. for c in data:
  106. st = self._st
  107. if st == 0:
  108. if c == SYNC0:
  109. self._st = 1
  110. else:
  111. # inter-frame byte: accumulate the firmware's plain-text diagnostic lines
  112. self._text.append(c)
  113. if c == 0x0A or len(self._text) > 240:
  114. self._flush_text(out)
  115. elif st == 1:
  116. self._st = 2 if c == SYNC1 else (1 if c == SYNC0 else 0)
  117. elif st == 2:
  118. self._type = c
  119. self._sum = c
  120. self._st = 3
  121. elif st == 3:
  122. self._len = c
  123. self._sum = (self._sum + c) & 0xFF
  124. self._buf = bytearray()
  125. self._st = 5 if c == 0 else 4
  126. elif st == 4:
  127. self._buf.append(c)
  128. self._sum = (self._sum + c) & 0xFF
  129. if len(self._buf) >= self._len:
  130. self._st = 5
  131. elif st == 5:
  132. if c == self._sum:
  133. out.append((self._type, bytes(self._buf)))
  134. self._st = 0
  135. return out