xap.py 9.7 KB

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