Sean O'Brien | 2c47301 | 2020-09-09 16:50:32 -0700 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | # Copyright 2020 The Chromium OS Authors. All rights reserved. |
| 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | |
| 6 | import argparse |
| 7 | import array |
| 8 | import fcntl |
| 9 | import hidtools.hidraw |
| 10 | import sys |
| 11 | import time |
| 12 | |
| 13 | # Definitions from USI HID descriptor spec v1.0 |
| 14 | FIELD_BITS = { |
| 15 | # Get/Set features: |
| 16 | 'color': 8, |
| 17 | 'width': 8, |
| 18 | # Diagnostic command feature: |
| 19 | 'preamble': 3, |
| 20 | 'index': 3, |
| 21 | 'command_id': 6, |
| 22 | 'command_data': 16, |
| 23 | 'crc': 5 |
| 24 | } |
| 25 | |
| 26 | # Get/Set feature options |
| 27 | STYLES = ['ink', 'pencil', 'highlighter', 'marker', 'brush', 'none'] |
| 28 | ALL_BUTTONS = ['unimplemented', 'barrel', 'secondary', 'eraser', 'disabled'] |
| 29 | REAL_BUTTONS = ALL_BUTTONS[1:-1] |
| 30 | ASSIGNABLE_BUTTONS = ALL_BUTTONS[1:] |
| 31 | |
| 32 | # Diagnostic command constants |
| 33 | DEFAULT_PREAMBLE = 0x4 |
| 34 | CRC_POLYNOMIAL = 0b111001 |
| 35 | CRC_POLYNOMIAL_LENGTH = 6 |
| 36 | |
| 37 | # These usages only appear in a single feature report as defined |
| 38 | # by the USI HID descriptor spec v1.0, so we can use them to |
| 39 | # look for the feature reports. |
| 40 | UNIQUE_REPORT_USAGES = { |
| 41 | 'color': [ |
| 42 | 0x5c, # Preferred color |
| 43 | 0x5d # Preferred color is locked |
| 44 | ], |
| 45 | 'width': [ |
| 46 | 0x5e, # Preferred line width |
| 47 | 0x5f # Preferred line width is locked |
| 48 | ], |
| 49 | 'style': [ |
| 50 | 0x70, # Preferred line style |
| 51 | 0x71, # Preferred line style is locked |
| 52 | 0x72, # Ink |
| 53 | 0x73, # Pencil |
| 54 | 0x74, # Highlighter |
| 55 | 0x75, # Chisel Marker |
| 56 | 0x76, # Brush |
| 57 | 0x77 # No preferred line style |
| 58 | ], |
| 59 | 'buttons': [ |
| 60 | 0xa4, # Switch unimplemented |
| 61 | 0x44, # Barrel switch |
| 62 | 0x5a, # Secondary barrel switch |
| 63 | 0x45, # Eraser |
| 64 | 0xa3 # Switch disabled |
| 65 | ], |
| 66 | 'firmware': [ |
| 67 | 0x90, # Transducer software info |
| 68 | 0x91, # Transducer vendor ID |
| 69 | 0x92, # Transducer product ID |
| 70 | 0x2a # Software version |
| 71 | ], |
| 72 | 'usi_version': [0x2b], # Protocol version |
| 73 | 'diagnostic': [0x80], # Digitizer Diagnostic |
| 74 | 'index': [0xa6] # Transducer index selector |
| 75 | } |
| 76 | |
| 77 | # ioctl definitions from <ioctl.h> |
| 78 | _IOC_NRBITS = 8 |
| 79 | _IOC_TYPEBITS = 8 |
| 80 | _IOC_SIZEBITS = 14 |
| 81 | |
| 82 | _IOC_NRSHIFT = 0 |
| 83 | _IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS |
| 84 | _IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS |
| 85 | _IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS |
| 86 | |
| 87 | _IOC_WRITE = 1 |
| 88 | _IOC_READ = 2 |
| 89 | |
| 90 | |
| 91 | def _IOC(dir, type, nr, size): |
| 92 | return ((dir << _IOC_DIRSHIFT) | (ord(type) << _IOC_TYPESHIFT) | |
| 93 | (nr << _IOC_NRSHIFT) | (size << _IOC_SIZESHIFT)) |
| 94 | |
| 95 | |
| 96 | def HIDIOCSFEATURE(size): |
| 97 | """ set report feature """ |
| 98 | return _IOC(_IOC_WRITE | _IOC_READ, 'H', 0x06, size) |
| 99 | |
| 100 | |
| 101 | def HIDIOCGFEATURE(size): |
| 102 | """ get report feature """ |
| 103 | return _IOC(_IOC_WRITE | _IOC_READ, 'H', 0x07, size) |
| 104 | |
| 105 | |
| 106 | class FeatureNotFoundError(ValueError): |
| 107 | pass |
| 108 | |
| 109 | |
| 110 | def log(message, end='\n'): |
| 111 | print(message, end=end, file=sys.stderr) |
| 112 | sys.stderr.flush() |
| 113 | |
| 114 | |
| 115 | def field_max(field_name): |
| 116 | return 2**FIELD_BITS[field_name] - 1 |
| 117 | |
| 118 | |
| 119 | def check_in_range(field_name, value): |
| 120 | min_val = 0 |
| 121 | max_val = field_max(field_name) |
| 122 | if value < 0 or value > max_val: |
| 123 | raise ValueError('{} must be between {} and {}, inclusive'.format( |
| 124 | field_name, min_val, max_val)) |
| 125 | |
| 126 | |
| 127 | def find_feature_report_id(rdesc, feature_name): |
| 128 | """Find the correct report ID for the feature. |
| 129 | |
| 130 | Search the report descriptor for a usage unique to the desired feature |
| 131 | report. |
| 132 | """ |
| 133 | report_id = None |
| 134 | usage_found = False |
| 135 | for item in rdesc.rdesc_items: |
| 136 | if item.item == 'Report ID': |
| 137 | report_id = item.value |
| 138 | elif item.item == 'Usage' and item.value in UNIQUE_REPORT_USAGES[ |
| 139 | feature_name]: |
| 140 | usage_found = True |
| 141 | elif item.item == 'Feature': |
| 142 | if usage_found and report_id is not None: |
| 143 | return report_id |
| 144 | elif item.item == 'Input' or item.item == 'Output': |
| 145 | usage_found = False |
| 146 | |
| 147 | raise FeatureNotFoundError( |
| 148 | 'Feature report not found for {}'.format(feature_name)) |
| 149 | |
| 150 | |
| 151 | def set_stylus_index(device, stylus_index): |
| 152 | """Send a report to set the stylus index. |
| 153 | |
| 154 | Tell the controller which stylus index the next 'get feature' command should |
| 155 | return information for. |
| 156 | """ |
| 157 | try: |
| 158 | report_id = find_feature_report_id(device.report_descriptor, 'index') |
| 159 | except FeatureNotFoundError as e: |
| 160 | if stylus_index != 1: |
| 161 | raise e |
| 162 | # Some devices only support one stylus and don't have a feature to set |
| 163 | # the stylus index, so continue with a warning if the desired stylus |
| 164 | # index is 1. |
| 165 | log('WARNING: {}. Proceeding regardless.'.format(str(e))) |
| 166 | return |
| 167 | buf = array.array('B', [report_id, stylus_index]) |
| 168 | fcntl.ioctl(device.device.fileno(), HIDIOCSFEATURE(len(buf)), buf) |
| 169 | |
| 170 | |
| 171 | def get_ioctl(device, buf): |
| 172 | """Send a 'get feature' ioctl to the device and return the results. |
| 173 | |
| 174 | Repeatedly poll until the device gets the information from the stylus, |
| 175 | so the user can run this before pairing the stylus. |
| 176 | """ |
| 177 | while fcntl.ioctl(device.device.fileno(), HIDIOCGFEATURE(len(buf)), |
| 178 | buf) == 0: |
| 179 | log('.', end='') |
| 180 | time.sleep(0.1) |
| 181 | log('') |
| 182 | return (buf) |
| 183 | |
| 184 | |
| 185 | def get_feature(device, stylus_index, feature_name): |
| 186 | report_id = find_feature_report_id(device.report_descriptor, feature_name) |
| 187 | set_stylus_index(device, stylus_index) |
| 188 | |
| 189 | buf = array.array('B', [0] * 64) |
| 190 | buf[0] = report_id |
| 191 | |
| 192 | return get_ioctl(device, buf) |
| 193 | |
| 194 | |
| 195 | def calculate_crc(data): |
| 196 | """Calculate the cyclic redundancy check. |
| 197 | |
| 198 | Calculate the cyclic redundancy check for diagnostic commands according to |
| 199 | the USI spec. |
| 200 | """ |
| 201 | data_len = ( |
| 202 | FIELD_BITS['command_data'] + |
| 203 | FIELD_BITS['command_id'] + |
| 204 | FIELD_BITS['index']) |
| 205 | crc_polynomial = CRC_POLYNOMIAL |
| 206 | msb_mask = 1 << (data_len - 1) |
| 207 | crc_polynomial = crc_polynomial << (data_len - CRC_POLYNOMIAL_LENGTH) |
| 208 | for _ in range(data_len): |
| 209 | if data & msb_mask: |
| 210 | data = data ^ crc_polynomial |
| 211 | data = data << 1 |
| 212 | return data >> (data_len - (CRC_POLYNOMIAL_LENGTH - 1)) |
| 213 | |
| 214 | |
| 215 | def diagnostic(device, args): |
| 216 | check_in_range('command_id', args.command_id) |
| 217 | check_in_range('command_data', args.data) |
| 218 | check_in_range('index', args.index) |
| 219 | check_in_range('preamble', args.preamble) |
| 220 | |
| 221 | command = args.data |
| 222 | command = args.command_id | (command << FIELD_BITS['command_id']) |
| 223 | command = args.index | (command << FIELD_BITS['index']) |
| 224 | |
| 225 | if args.crc is None: |
| 226 | crc = calculate_crc(command) |
| 227 | else: |
| 228 | crc = args.crc |
| 229 | check_in_range('crc', args.crc) |
| 230 | |
| 231 | full_command = crc |
| 232 | full_command = command | ( |
| 233 | full_command << (FIELD_BITS['command_data'] + |
| 234 | FIELD_BITS['command_id'] + |
| 235 | FIELD_BITS['index'])) |
| 236 | full_command = args.preamble | (full_command << FIELD_BITS['preamble']) |
| 237 | command_bytes = full_command.to_bytes(8, byteorder='little') |
| 238 | |
| 239 | report_id = find_feature_report_id(device.report_descriptor, 'diagnostic') |
| 240 | buf = array.array('B', [report_id] + list(command_bytes)) |
| 241 | |
| 242 | # Get a feature from the stylus to make sure it is paired. |
| 243 | get_feature(device, args.index, 'color') |
| 244 | fcntl.ioctl(device.device.fileno(), HIDIOCSFEATURE(len(buf)), buf) |
| 245 | |
| 246 | return get_ioctl(device, buf) |
| 247 | |
| 248 | |
| 249 | def format_bytes(buf): |
| 250 | """Format bytes for printing. |
| 251 | |
| 252 | Given a little-endian list of bytes, format them for printing as a single |
| 253 | hex value. |
| 254 | """ |
| 255 | ret = '0x' |
| 256 | for byte in reversed(buf): |
| 257 | ret += '{:02x}'.format(byte) |
| 258 | return ret |
| 259 | |
| 260 | |
| 261 | def print_diagnostic(buf): |
| 262 | error_bit = (buf[3] & 0x40) >> 6 |
| 263 | error_code = buf[3] & 0x3f |
| 264 | print('full response: {}'.format(format_bytes(buf[1:]))) |
| 265 | print('error information available? {}'.format(error_bit)) |
| 266 | print('error code: 0x{:02x}'.format(error_code)) |
| 267 | print('command response: {}'.format(format_bytes(buf[1:3]))) |
| 268 | |
| 269 | |
| 270 | def print_feature(buf, feature_name): |
| 271 | # The first two elements of buf hold the report ID and stylus index, |
| 272 | # which do not need to be printed |
| 273 | if feature_name == 'color': |
| 274 | print('transducer color: {}'.format(buf[2])) |
| 275 | elif feature_name == 'width': |
| 276 | print('transducer width: {}'.format(buf[2])) |
| 277 | elif feature_name == 'style': |
| 278 | style = buf[2] |
| 279 | style_index = style - 1 |
| 280 | if 0 <= style_index < len(STYLES): |
| 281 | style_name = STYLES[style_index] |
| 282 | else: |
| 283 | style_name = str(style) |
| 284 | print('transducer style: {}'.format(style_name)) |
| 285 | elif feature_name == 'buttons': |
| 286 | for real_button_index in range(len(REAL_BUTTONS)): |
| 287 | real_button_name = REAL_BUTTONS[real_button_index] |
| 288 | virtual_button = buf[2 + real_button_index] |
| 289 | virtual_button_index = virtual_button - 1 |
| 290 | if 0 <= virtual_button_index < len(ALL_BUTTONS): |
| 291 | virtual_button_name = ALL_BUTTONS[virtual_button_index] |
| 292 | else: |
| 293 | virtual_button_name = str(virtual_button) |
| 294 | print("'{}' maps to '{}'".format(real_button_name, |
| 295 | virtual_button_name)) |
| 296 | elif feature_name == 'firmware': |
| 297 | print('vendor ID: {}'.format(format_bytes(buf[2:4]))) |
| 298 | print('product ID: {}'.format(format_bytes(buf[4:12]))) |
| 299 | print('firmware version: {}.{}'.format(buf[12], buf[13])) |
| 300 | elif feature_name == 'usi_version': |
| 301 | print('USI version: {}.{}'.format(buf[2], buf[3])) |
| 302 | |
| 303 | |
| 304 | def set_feature(device, stylus_index, feature_name, value): |
| 305 | buf = get_feature(device, stylus_index, feature_name) |
| 306 | buf[2] = value |
| 307 | fcntl.ioctl(device.device.fileno(), HIDIOCSFEATURE(len(buf)), buf) |
| 308 | |
| 309 | |
| 310 | def set_number_feature(device, stylus_index, feature_name, value): |
| 311 | try: |
| 312 | check_in_range(feature_name, value) |
| 313 | except ValueError as e: |
| 314 | log('ERROR: {}. Proceeding with setting other features.'.format(str(e))) |
| 315 | return |
| 316 | set_feature(device, stylus_index, feature_name, value) |
| 317 | |
| 318 | |
| 319 | def set_style_feature(device, stylus_index, style_name): |
| 320 | style = STYLES.index(style_name) + 1 |
| 321 | set_feature(device, stylus_index, 'style', style) |
| 322 | |
| 323 | |
| 324 | def set_buttons_feature(device, stylus_index, mappings): |
| 325 | buf = get_feature(device, stylus_index, 'buttons') |
| 326 | base_offset = 2 |
| 327 | for real_button_name, virtual_button_name in mappings: |
| 328 | offset = base_offset + REAL_BUTTONS.index(real_button_name) |
| 329 | virtual_button = ALL_BUTTONS.index(virtual_button_name) + 1 |
| 330 | buf[offset] = virtual_button |
| 331 | fcntl.ioctl(device.device.fileno(), HIDIOCSFEATURE(len(buf)), buf) |
| 332 | |
| 333 | |
| 334 | def set_features(device, args): |
| 335 | if args.color is not None: |
| 336 | set_number_feature(device, args.index, 'color', args.color) |
| 337 | if args.width is not None: |
| 338 | set_number_feature(device, args.index, 'width', args.width) |
| 339 | if args.style is not None: |
| 340 | set_style_feature(device, args.index, args.style) |
| 341 | if args.buttons is not None: |
| 342 | set_buttons_feature(device, args.index, args.buttons) |
| 343 | |
| 344 | |
| 345 | def parse_arguments(): |
| 346 | parser = argparse.ArgumentParser( |
| 347 | description='USI test tool', |
| 348 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) |
| 349 | parser.add_argument( |
| 350 | '-p', |
| 351 | '--path', |
| 352 | default='/dev/hidraw0', |
| 353 | help='the path of the USI device') |
| 354 | subparsers = parser.add_subparsers(dest='command') |
| 355 | |
| 356 | descriptor_help = 'print the report descriptor in human-readable format' |
| 357 | subparsers.add_parser( |
| 358 | 'descriptor', help=descriptor_help, description=descriptor_help) |
| 359 | |
| 360 | data_help = 'print incoming data reports in human-readable format' |
| 361 | subparsers.add_parser('data', help=data_help, description=data_help) |
| 362 | |
| 363 | parser_get = subparsers.add_parser( |
| 364 | 'get', |
| 365 | help='read a feature from a stylus', |
| 366 | description='Send a HID feature request to the device and print the ' |
| 367 | 'response. Will wait for the specified stylus index to pair first.') |
| 368 | parser_get.add_argument( |
| 369 | 'feature', |
| 370 | choices=[ |
| 371 | 'color', 'width', 'style', 'buttons', 'firmware', 'usi_version' |
| 372 | ], |
| 373 | help='which feature to read') |
| 374 | parser_get.add_argument( |
| 375 | '-i', |
| 376 | '--index', |
| 377 | default=1, |
| 378 | type=int, |
| 379 | help='which stylus to read values for, by index ' |
| 380 | '(default: %(default)s)') |
| 381 | |
| 382 | parser_set = subparsers.add_parser( |
| 383 | 'set', |
| 384 | help='change stylus features', |
| 385 | description='Set a stylus feature via HID feature report. Will wait ' |
| 386 | 'for the specified stylus index to pair first.') |
| 387 | parser_set.add_argument( |
| 388 | '-i', |
| 389 | '--index', |
| 390 | default=1, |
| 391 | type=int, |
| 392 | help='which stylus to set values for, by index (default: %(default)s)') |
| 393 | parser_set.add_argument( |
| 394 | '-c', |
| 395 | '--color', |
| 396 | type=int, |
| 397 | metavar='[{},{}]'.format(0, field_max('color')), |
| 398 | help='set the stylus color to the specified value ' |
| 399 | 'in the range ({},{})'.format(0, field_max('color'))) |
| 400 | parser_set.add_argument( |
| 401 | '-w', |
| 402 | '--width', |
| 403 | type=int, |
| 404 | metavar='[{},{}]'.format(0, field_max('width')), |
| 405 | help='set the stylus width to the specified value ' |
| 406 | 'in the range ({},{})'.format(0, field_max('width'))) |
| 407 | parser_set.add_argument( |
| 408 | '-s', |
| 409 | '--style', |
| 410 | choices=STYLES, |
| 411 | help='set the stylus style to the specified value') |
| 412 | parser_set.add_argument( |
| 413 | '-b', |
| 414 | '--button', |
| 415 | dest='buttons', |
| 416 | nargs=2, |
| 417 | action='append', |
| 418 | metavar=('{%s}' % ','.join(REAL_BUTTONS), |
| 419 | '{%s}' % ','.join(ASSIGNABLE_BUTTONS)), |
| 420 | help='Remap a button to emulate another. ' |
| 421 | 'For example: \'usi-test set -b barrel eraser\' will set the ' |
| 422 | 'barrel button to act as an eraser button. You may set multiple ' |
| 423 | 'buttons by setting this option multiple times.') |
| 424 | |
| 425 | parser_diag = subparsers.add_parser( |
| 426 | 'diagnostic', |
| 427 | help='send a diagnostic command', |
| 428 | description='Send the specified diagnostic command to a stylus and ' |
| 429 | 'read the response. You may set the preamble and cyclic redundancy ' |
| 430 | 'check manually, but by default the tool will send the preamble and ' |
| 431 | 'CRC defined by the USI spec.') |
| 432 | parser_diag.add_argument( |
| 433 | 'command_id', type=int, help='which diagnostic command to send') |
| 434 | parser_diag.add_argument( |
| 435 | 'data', type=int, help='the data to send with the diagnostic command') |
| 436 | parser_diag.add_argument( |
| 437 | '-i', |
| 438 | '--index', |
| 439 | default=1, |
| 440 | type=int, |
| 441 | help='which stylus to send command to, by index (default: %(default)s)') |
| 442 | parser_diag.add_argument( |
| 443 | '-c', |
| 444 | '--crc', |
| 445 | type=int, |
| 446 | help='the custom cyclic redundancy check for the diagnostic command. ' |
| 447 | 'By default this tool will send the appropriate crc calculated by the ' |
| 448 | 'algorithm defined in the USI spec.') |
| 449 | parser_diag.add_argument( |
| 450 | '-p', |
| 451 | '--preamble', |
| 452 | type=int, |
| 453 | default=DEFAULT_PREAMBLE, |
| 454 | help='the preamble for the diagnostic command. ' |
| 455 | 'By default this tool will send the preamble defined in the USI spec') |
| 456 | |
| 457 | args = parser.parse_args() |
| 458 | |
| 459 | return args |
| 460 | |
| 461 | |
| 462 | def main(): |
| 463 | args = parse_arguments() |
| 464 | |
| 465 | fd = open(args.path, 'rb+') |
| 466 | device = hidtools.hidraw.HidrawDevice(fd) |
| 467 | |
| 468 | if args.command == 'descriptor': |
| 469 | device.dump(sys.stdout) |
| 470 | elif args.command == 'data': |
| 471 | while True: |
| 472 | device.dump(sys.stdout) |
| 473 | device.read_events() |
| 474 | elif args.command == 'get': |
| 475 | buf = get_feature(device, args.index, args.feature) |
| 476 | print_feature(buf, args.feature) |
| 477 | elif args.command == 'set': |
| 478 | set_features(device, args) |
| 479 | elif args.command == 'diagnostic': |
| 480 | buf = diagnostic(device, args) |
| 481 | print_diagnostic(buf) |
| 482 | |
| 483 | |
| 484 | if __name__ == '__main__': |
| 485 | main() |