xap.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. """Interactions with compatible XAP devices
  2. """
  3. import cmd
  4. import json
  5. import random
  6. import gzip
  7. import threading
  8. import functools
  9. from enum import IntFlag
  10. from platform import platform
  11. from milc import cli
  12. from qmk.keyboard import render_layout
  13. from qmk.xap.common import get_xap_keycodes
  14. KEYCODE_MAP = get_xap_keycodes('latest')
  15. def _u32toBCD(val): # noqa: N802
  16. """Create BCD string
  17. """
  18. return f'{val>>24}.{val>>16 & 0xFF}.{val & 0xFFFF}'
  19. class XAPFlags(IntFlag):
  20. SUCCESS = 0x01
  21. class XAPDevice:
  22. def __init__(self, dev):
  23. """Constructor opens hid device and starts dependent services
  24. """
  25. self.responses = {}
  26. self.dev = hid.Device(path=dev['path'])
  27. self.bg = threading.Thread(target=self._read_loop, daemon=True)
  28. self.bg.start()
  29. def _read_loop(self):
  30. """Background thread to signal waiting transactions
  31. """
  32. while 1:
  33. array_alpha = self.dev.read(64, 100)
  34. if array_alpha:
  35. token = str(array_alpha[:2])
  36. event = self.responses.get(token)
  37. if event:
  38. event._ret = array_alpha
  39. event.set()
  40. def _query_device_info(self):
  41. datalen = int.from_bytes(self.transaction(0x01, 0x05) or bytes(0), "little")
  42. if not datalen:
  43. return {}
  44. data = []
  45. offset = 0
  46. while offset < datalen:
  47. chunk = self.transaction(0x01, 0x06, offset)
  48. data += chunk
  49. offset += len(chunk)
  50. str_data = gzip.decompress(bytearray(data[:datalen]))
  51. return json.loads(str_data)
  52. def listen(self):
  53. """Receive a "broadcast" message
  54. """
  55. token = b"\xFF\xFF"
  56. event = threading.Event()
  57. self.responses[str(token)] = event
  58. event.wait()
  59. return event._ret
  60. def transaction(self, sub, route, *args):
  61. """Request/Receive
  62. """
  63. # token cannot start with zero or be FFFF
  64. token = random.randrange(0x0100, 0xFFFE).to_bytes(2, byteorder='big')
  65. # send with padding
  66. # TODO: this code is total garbage
  67. args_data = []
  68. args_len = 2
  69. if len(args) == 1:
  70. if isinstance(args[0], (bytes, bytearray)):
  71. args_len += len(args[0])
  72. args_data = args[0]
  73. else:
  74. args_len += 2
  75. args_data = args[0].to_bytes(2, byteorder='little')
  76. padding_len = 64 - 3 - args_len
  77. padding = b"\x00" * padding_len
  78. if args_data:
  79. padding = args_data + padding
  80. buffer = token + args_len.to_bytes(1, byteorder='little') + sub.to_bytes(1, byteorder='little') + route.to_bytes(1, byteorder='little') + padding
  81. # prepend 0 on windows because reasons...
  82. if 'windows' in platform().lower():
  83. buffer = b"\x00" + buffer
  84. event = threading.Event()
  85. self.responses[str(token)] = event
  86. self.dev.write(buffer)
  87. event.wait(timeout=1)
  88. self.responses.pop(str(token), None)
  89. if not hasattr(event, '_ret'):
  90. return None
  91. array_alpha = event._ret
  92. if int(array_alpha[2]) != XAPFlags.SUCCESS:
  93. return None
  94. payload_len = int(array_alpha[3])
  95. return array_alpha[4:4 + payload_len]
  96. @functools.cache
  97. def version(self):
  98. ver = int.from_bytes(self.transaction(0x00, 0x00) or bytes(0), 'little')
  99. return {'xap': _u32toBCD(ver)}
  100. @functools.cache
  101. def info(self):
  102. data = self._query_device_info()
  103. data['_id'] = self.transaction(0x01, 0x08)
  104. data['xap'] = self.version()['xap']
  105. return data
  106. def unlock(self):
  107. self.transaction(0x00, 0x04)
  108. class XAPClient:
  109. @staticmethod
  110. def _lazy_imports():
  111. # Lazy load to avoid missing dependency issues
  112. global hid
  113. import hid
  114. @staticmethod
  115. def list(search=None):
  116. """Find compatible XAP devices
  117. """
  118. XAPClient._lazy_imports()
  119. def _is_xap_usage(x):
  120. return x['usage_page'] == 0xFF51 and x['usage'] == 0x0058
  121. def _is_filtered_device(x):
  122. name = "%04x:%04x" % (x['vendor_id'], x['product_id'])
  123. return name.lower().startswith(search.lower())
  124. devices = filter(_is_xap_usage, hid.enumerate())
  125. if search:
  126. devices = filter(_is_filtered_device, devices)
  127. return list(devices)
  128. def connect(self, dev):
  129. """Connect to a given XAP device
  130. """
  131. XAPClient._lazy_imports()
  132. return XAPDevice(dev)
  133. # def _query_device_secure(device):
  134. # secure = int.from_bytes(_xap_transaction(device, 0x00, 0x03), 'little')
  135. # secure = 'unlocked' if secure == 2 else 'LOCKED'
  136. # return {'secure': secure}
  137. #
  138. # def xap_dummy(device):
  139. # # get layer count
  140. # layers = _xap_transaction(device, 0x04, 0x02)
  141. # layers = int.from_bytes(layers, "little")
  142. # print(f'layers:{layers}')
  143. # # get keycode [layer:0, row:0, col:0]
  144. # # keycode = _xap_transaction(device, 0x04, 0x03, b"\x00\x00\x00")
  145. # # get encoder [layer:0, index:0, clockwise:0]
  146. # keycode = _xap_transaction(device, 0x04, 0x04, b"\x00\x00\x00")
  147. # keycode = int.from_bytes(keycode, "little")
  148. # print(f'keycode:{KEYCODE_MAP.get(keycode, "unknown")}[{keycode}]')
  149. # # set encoder [layer:0, index:0, clockwise:0, keycode:KC_A]
  150. # _xap_transaction(device, 0x05, 0x04, b"\x00\x00\x00\x04\00")
  151. def print_dotted_output(kb_info_json, prefix=''):
  152. """Print the info.json in a plain text format with dot-joined keys.
  153. """
  154. for key in sorted(kb_info_json):
  155. new_prefix = f'{prefix}.{key}' if prefix else key
  156. if key in ['parse_errors', 'parse_warnings']:
  157. continue
  158. elif key == 'layouts' and prefix == '':
  159. cli.echo(' {fg_blue}layouts{fg_reset}: %s', ', '.join(sorted(kb_info_json['layouts'].keys())))
  160. elif isinstance(kb_info_json[key], bytes):
  161. conv = "".join(["{:02X}".format(b) for b in kb_info_json[key]])
  162. cli.echo(' {fg_blue}%s{fg_reset}: %s', new_prefix, conv)
  163. elif isinstance(kb_info_json[key], dict):
  164. print_dotted_output(kb_info_json[key], new_prefix)
  165. elif isinstance(kb_info_json[key], list):
  166. data = kb_info_json[key]
  167. if len(data) and isinstance(data[0], dict):
  168. for index, item in enumerate(data, start=0):
  169. cli.echo(' {fg_blue}%s.%s{fg_reset}: %s', new_prefix, index, str(item))
  170. else:
  171. cli.echo(' {fg_blue}%s{fg_reset}: %s', new_prefix, ', '.join(map(str, data)))
  172. else:
  173. cli.echo(' {fg_blue}%s{fg_reset}: %s', new_prefix, kb_info_json[key])
  174. def _list_devices():
  175. """Dump out available devices
  176. """
  177. cli.log.info('Available devices:')
  178. devices = XAPClient.list()
  179. for dev in devices:
  180. device = XAPClient().connect(dev)
  181. data = device.info()
  182. cli.log.info(" %04x:%04x %s %s [API:%s]", dev['vendor_id'], dev['product_id'], dev['manufacturer_string'], dev['product_string'], data['xap'])
  183. if cli.config.general.verbose:
  184. # TODO: better formatting like "lsusb -v"?
  185. print_dotted_output(data)
  186. class XAPShell(cmd.Cmd):
  187. intro = 'Welcome to the XAP shell. Type help or ? to list commands.\n'
  188. prompt = 'Ψ> '
  189. def __init__(self, device):
  190. cmd.Cmd.__init__(self)
  191. self.device = device
  192. # cache keycodes for this device
  193. self.keycodes = get_xap_keycodes(device.version()['xap'])
  194. def do_about(self, arg):
  195. """Prints out the current version of QMK with a build date
  196. """
  197. # TODO: request stuff?
  198. print(self.device.info()['xap'])
  199. def do_unlock(self, arg):
  200. """Initiate secure unlock
  201. """
  202. self.device.unlock()
  203. print("Done")
  204. def do_listen(self, arg):
  205. """Log out XAP broadcast messages
  206. """
  207. try:
  208. cli.log.info("Listening for XAP broadcasts...")
  209. while 1:
  210. array_alpha = self.device.listen()
  211. if array_alpha[2] == 1:
  212. cli.log.info(" Broadcast: Secure[%02x]", array_alpha[4])
  213. else:
  214. cli.log.info(" Broadcast: type[%02x] data:[%02x]", array_alpha[2], array_alpha[4])
  215. except KeyboardInterrupt:
  216. cli.log.info("Stopping...")
  217. def do_keycode(self, arg):
  218. """Prints out the keycode value of a certain layer, row, and column
  219. """
  220. data = bytes(map(int, arg.split()))
  221. if len(data) != 3:
  222. cli.log.error("Invalid args")
  223. return
  224. keycode = self.device.transaction(0x04, 0x03, data)
  225. keycode = int.from_bytes(keycode, "little")
  226. print(f'keycode:{self.keycodes.get(keycode, "unknown")}[{keycode}]')
  227. def do_keymap(self, arg):
  228. """Prints out the keycode values of a certain layer
  229. """
  230. data = bytes(map(int, arg.split()))
  231. if len(data) != 1:
  232. cli.log.error("Invalid args")
  233. return
  234. info = self.device.info()
  235. rows = info['matrix_size']['rows']
  236. cols = info['matrix_size']['cols']
  237. for r in range(rows):
  238. for c in range(cols):
  239. q = data + r.to_bytes(1, byteorder='little') + c.to_bytes(1, byteorder='little')
  240. keycode = self.device.transaction(0x04, 0x03, q)
  241. keycode = int.from_bytes(keycode, "little")
  242. print(f'| {self.keycodes.get(keycode, "unknown").ljust(7)} ', end='', flush=True)
  243. print('|')
  244. def do_layer(self, arg):
  245. """Renders keycode values of a certain layer
  246. """
  247. data = bytes(map(int, arg.split()))
  248. if len(data) != 1:
  249. cli.log.error("Invalid args")
  250. return
  251. info = self.device.info()
  252. # Assumptions on selected layout rather than prompt
  253. first_layout = next(iter(info['layouts']))
  254. layout = info['layouts'][first_layout]['layout']
  255. keycodes = []
  256. for item in layout:
  257. q = data + bytes(item['matrix'])
  258. keycode = self.device.transaction(0x04, 0x03, q)
  259. keycode = int.from_bytes(keycode, "little")
  260. keycodes.append(self.keycodes.get(keycode, "???"))
  261. print(render_layout(layout, False, keycodes))
  262. def do_exit(self, line):
  263. """Quit shell
  264. """
  265. return True
  266. def do_EOF(self, line): # noqa: N802
  267. """Quit shell (ctrl+D)
  268. """
  269. return True
  270. def loop(self):
  271. """Wrapper for cmdloop that handles ctrl+C
  272. """
  273. try:
  274. self.cmdloop()
  275. print('')
  276. except KeyboardInterrupt:
  277. print('^C')
  278. return False
  279. @cli.argument('-d', '--device', help='device to select - uses format <pid>:<vid>.')
  280. @cli.argument('-l', '--list', arg_only=True, action='store_true', help='List available devices.')
  281. @cli.argument('-i', '--interactive', arg_only=True, action='store_true', help='Start interactive shell.')
  282. @cli.argument('action', nargs='*', default=['listen'], arg_only=True)
  283. @cli.subcommand('Acquire debugging information from usb XAP devices.', hidden=False if cli.config.user.developer else True)
  284. def xap(cli):
  285. """Acquire debugging information from XAP devices
  286. """
  287. if cli.args.list:
  288. return _list_devices()
  289. # Connect to first available device
  290. devices = XAPClient.list()
  291. if not devices:
  292. cli.log.error("No devices found!")
  293. return False
  294. dev = devices[0]
  295. cli.log.info("Connecting to:%04x:%04x %s %s", dev['vendor_id'], dev['product_id'], dev['manufacturer_string'], dev['product_string'])
  296. device = XAPClient().connect(dev)
  297. # shell?
  298. if cli.args.interactive:
  299. XAPShell(device).loop()
  300. return True
  301. XAPShell(device).onecmd(" ".join(cli.args.action))