xap.py 8.8 KB

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