xap.py 10 KB

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