Add usi-test tool
Add usi-test tool to inspect Universal Stylus Intiative (USI) device
communication via HID reports.
The tool currently supports:
* printing the report descriptor in human-readable formant
* printing incoming events in human-readable format
* setting and getting parameters for a paired stylus, and
* sending diagnostic commands a paired stylus.
BUG=b:165820387
TEST=build image including the tool, and test all commands
Change-Id: I7a92fd29cd677a5bed0bff51831a67f4f975df33
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/usi-test/+/2405391
Commit-Queue: Sean O'Brien <seobrien@chromium.org>
Tested-by: Sean O'Brien <seobrien@chromium.org>
Reviewed-by: Harry Cutts <hcutts@chromium.org>
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..894016a
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1 @@
+seobrien@chromium.org
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..70daedc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,128 @@
+# USI Test Tool
+
+[TOC]
+
+## Introduction
+
+This repository contains a tool for testing a [Universal Stylus Initiative][usi]
+(USI) digitizer and stylus at the HID protocol level.
+
+
+## Setting up
+
+Make sure that your device has a dev or test image. You can use [cros flash]
+to flash a test image. Then you will need to enter the [command prompt].
+Finally, you can run the tool with the `usi-test` command.
+
+## Commands
+
+`usi-test` has five subcommands: `descriptor`, `data`, `get`, `set`, and
+`diagnostic`.
+
+For any of these commands, you can set the path to the device with the `-p` or
+`--path` option. The default device path is `/dev/hidraw0`.
+
+### descriptor
+
+This command simply prints out the report descriptor in human-readable format.
+
+example:
+```sh
+$ usi-test --path /dev/hidraw0 descriptor
+```
+
+### data
+
+This command prints incoming events from the device in human-readable format.
+You can quit with `Ctl-C`.
+
+example:
+```sh
+$ usi-test -p /dev/hidraw0 data
+```
+
+### get
+
+This command sends a HID feature request to the device and prints the response.
+All of these commands query a stylus paired with the device, which can be
+specified with the `-i` or `--index` option. This is the temporary index
+assigned to the stylus by the digitizer when it pairs. The default index is 1.
+
+This command will wait for the specified stylus to pair with the digitizer, so
+you can send the command without having the stylus pressed to the digitizer.
+
+The values you can query are:
+* `color`: The current color of the stylus stroke
+* `width`: The current width of the stylus stroke
+* `style`: The current style of the stylus stroke (ink, pencil, etc.)
+* `buttons`: The mapping from physical stylus buttons to the button types they
+ are set to be emulating.
+* `firmware`: The vendor ID, product ID, and firmware version of the stylus.
+* `usi_version`: The USI protocol version this pen is using.
+
+examples:
+```sh
+$ usi-test -p /dev/hidraw0 get color
+$ usi-test get width --index 1
+$ usi-test get buttons -i 2
+```
+
+### set
+
+This command sets the specified values for the stylus using a HID feature
+report. The stylus index is specified the same as with the `get` command.
+
+This command will wait for the specified stylus to pair with the digitizer, so
+you can send the command without having the stylus pressed to the digitizer.
+
+The values you can set are:
+* `--color`, `-c`: Values between 0 and 255. See the USI spec for color mapping.
+* `--width`, `-w`: Values between 0 and 255, in 0.1 mm increments. For example,
+ A value of 25 gives a stroke width of 2.5 mm.
+* `--style`, `-s`: Choose from ink, pencil, highlighter, marker, brush, and none
+* `--button`, `-b`: This option requires two arguments. It will set the first
+ button argument to emulate the second button argument. The first (physical)
+ button may be (barrel, secondary, eraser), and the second (emulated) button
+ may be (barrel, secondary, eraser, disabled)
+
+You may set one or all of the values in the same command. The button option
+can be set once for each physical button.
+
+examples:
+```sh
+$ usi-test set --color 50
+$ usi-test set -c 50
+$ usi-test set --style pencil --index 2
+$ usi-test set -c 50 -w 20 -s pencil
+ # set the barrel button to act as the secondary button:
+$ usi-test set --button barrel secondary
+$ usi-test set --button barrel secondary --button secondary eraser
+```
+
+### diagnostic
+
+This command sends a diagnostic command to the stylus and reads the response. If
+you want you can set the preamble and cyclic redundancy check (CRC) manually,
+but by default this tool will calculate and send the preamble and CRC defined by
+the USI spec. The stylus index can be set the same as with the `get` command.
+
+This command expects the command ID as the first argument, and the data as the
+second argument.
+
+It will print the full reponse from the stylus, and break the response into the
+error bit, error code, and command response.
+
+examples:
+```sh
+ # send the echo command, with value 8
+$ usi-test diagnostic 52 8
+full response: 0x0000000000000008
+error information available? 0
+error code: 0x00
+command response: 0x0008
+$ usi-test diagnostic 52 7 --preamble 2 --crc 3
+```
+
+[command prompt]: https://chromium.googlesource.com/chromiumos/docs/+/master/developer_mode.md#shell
+[cros flash]: https://chromium.googlesource.com/chromiumos/docs/+/master/cros_flash.md
+[usi]: https://universalstylus.org/
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..276357f
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,23 @@
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from setuptools import setup
+
+setup(name='usi-test',
+ version='1.0',
+ description='USI Device Testing Tool',
+ long_description='This contains tools for testing USI devices.',
+ py_modules=['usi_test'],
+ author='Sean O\'Brien',
+ author_email='seobrien@chromium.org',
+ license='BSD-Google',
+ entry_points={
+ 'console_scripts': [
+ 'usi-test = usi_test:main'
+ ]
+ },
+ python_requires='>=3.6',
+ install_requires=['hid-tools==0.2']
+)
+
diff --git a/usi_test.py b/usi_test.py
new file mode 100644
index 0000000..11d22c3
--- /dev/null
+++ b/usi_test.py
@@ -0,0 +1,485 @@
+#!/usr/bin/env python3
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import argparse
+import array
+import fcntl
+import hidtools.hidraw
+import sys
+import time
+
+# Definitions from USI HID descriptor spec v1.0
+FIELD_BITS = {
+ # Get/Set features:
+ 'color': 8,
+ 'width': 8,
+ # Diagnostic command feature:
+ 'preamble': 3,
+ 'index': 3,
+ 'command_id': 6,
+ 'command_data': 16,
+ 'crc': 5
+}
+
+# Get/Set feature options
+STYLES = ['ink', 'pencil', 'highlighter', 'marker', 'brush', 'none']
+ALL_BUTTONS = ['unimplemented', 'barrel', 'secondary', 'eraser', 'disabled']
+REAL_BUTTONS = ALL_BUTTONS[1:-1]
+ASSIGNABLE_BUTTONS = ALL_BUTTONS[1:]
+
+# Diagnostic command constants
+DEFAULT_PREAMBLE = 0x4
+CRC_POLYNOMIAL = 0b111001
+CRC_POLYNOMIAL_LENGTH = 6
+
+# These usages only appear in a single feature report as defined
+# by the USI HID descriptor spec v1.0, so we can use them to
+# look for the feature reports.
+UNIQUE_REPORT_USAGES = {
+ 'color': [
+ 0x5c, # Preferred color
+ 0x5d # Preferred color is locked
+ ],
+ 'width': [
+ 0x5e, # Preferred line width
+ 0x5f # Preferred line width is locked
+ ],
+ 'style': [
+ 0x70, # Preferred line style
+ 0x71, # Preferred line style is locked
+ 0x72, # Ink
+ 0x73, # Pencil
+ 0x74, # Highlighter
+ 0x75, # Chisel Marker
+ 0x76, # Brush
+ 0x77 # No preferred line style
+ ],
+ 'buttons': [
+ 0xa4, # Switch unimplemented
+ 0x44, # Barrel switch
+ 0x5a, # Secondary barrel switch
+ 0x45, # Eraser
+ 0xa3 # Switch disabled
+ ],
+ 'firmware': [
+ 0x90, # Transducer software info
+ 0x91, # Transducer vendor ID
+ 0x92, # Transducer product ID
+ 0x2a # Software version
+ ],
+ 'usi_version': [0x2b], # Protocol version
+ 'diagnostic': [0x80], # Digitizer Diagnostic
+ 'index': [0xa6] # Transducer index selector
+}
+
+# ioctl definitions from <ioctl.h>
+_IOC_NRBITS = 8
+_IOC_TYPEBITS = 8
+_IOC_SIZEBITS = 14
+
+_IOC_NRSHIFT = 0
+_IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS
+_IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS
+_IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS
+
+_IOC_WRITE = 1
+_IOC_READ = 2
+
+
+def _IOC(dir, type, nr, size):
+ return ((dir << _IOC_DIRSHIFT) | (ord(type) << _IOC_TYPESHIFT) |
+ (nr << _IOC_NRSHIFT) | (size << _IOC_SIZESHIFT))
+
+
+def HIDIOCSFEATURE(size):
+ """ set report feature """
+ return _IOC(_IOC_WRITE | _IOC_READ, 'H', 0x06, size)
+
+
+def HIDIOCGFEATURE(size):
+ """ get report feature """
+ return _IOC(_IOC_WRITE | _IOC_READ, 'H', 0x07, size)
+
+
+class FeatureNotFoundError(ValueError):
+ pass
+
+
+def log(message, end='\n'):
+ print(message, end=end, file=sys.stderr)
+ sys.stderr.flush()
+
+
+def field_max(field_name):
+ return 2**FIELD_BITS[field_name] - 1
+
+
+def check_in_range(field_name, value):
+ min_val = 0
+ max_val = field_max(field_name)
+ if value < 0 or value > max_val:
+ raise ValueError('{} must be between {} and {}, inclusive'.format(
+ field_name, min_val, max_val))
+
+
+def find_feature_report_id(rdesc, feature_name):
+ """Find the correct report ID for the feature.
+
+ Search the report descriptor for a usage unique to the desired feature
+ report.
+ """
+ report_id = None
+ usage_found = False
+ for item in rdesc.rdesc_items:
+ if item.item == 'Report ID':
+ report_id = item.value
+ elif item.item == 'Usage' and item.value in UNIQUE_REPORT_USAGES[
+ feature_name]:
+ usage_found = True
+ elif item.item == 'Feature':
+ if usage_found and report_id is not None:
+ return report_id
+ elif item.item == 'Input' or item.item == 'Output':
+ usage_found = False
+
+ raise FeatureNotFoundError(
+ 'Feature report not found for {}'.format(feature_name))
+
+
+def set_stylus_index(device, stylus_index):
+ """Send a report to set the stylus index.
+
+ Tell the controller which stylus index the next 'get feature' command should
+ return information for.
+ """
+ try:
+ report_id = find_feature_report_id(device.report_descriptor, 'index')
+ except FeatureNotFoundError as e:
+ if stylus_index != 1:
+ raise e
+ # Some devices only support one stylus and don't have a feature to set
+ # the stylus index, so continue with a warning if the desired stylus
+ # index is 1.
+ log('WARNING: {}. Proceeding regardless.'.format(str(e)))
+ return
+ buf = array.array('B', [report_id, stylus_index])
+ fcntl.ioctl(device.device.fileno(), HIDIOCSFEATURE(len(buf)), buf)
+
+
+def get_ioctl(device, buf):
+ """Send a 'get feature' ioctl to the device and return the results.
+
+ Repeatedly poll until the device gets the information from the stylus,
+ so the user can run this before pairing the stylus.
+ """
+ while fcntl.ioctl(device.device.fileno(), HIDIOCGFEATURE(len(buf)),
+ buf) == 0:
+ log('.', end='')
+ time.sleep(0.1)
+ log('')
+ return (buf)
+
+
+def get_feature(device, stylus_index, feature_name):
+ report_id = find_feature_report_id(device.report_descriptor, feature_name)
+ set_stylus_index(device, stylus_index)
+
+ buf = array.array('B', [0] * 64)
+ buf[0] = report_id
+
+ return get_ioctl(device, buf)
+
+
+def calculate_crc(data):
+ """Calculate the cyclic redundancy check.
+
+ Calculate the cyclic redundancy check for diagnostic commands according to
+ the USI spec.
+ """
+ data_len = (
+ FIELD_BITS['command_data'] +
+ FIELD_BITS['command_id'] +
+ FIELD_BITS['index'])
+ crc_polynomial = CRC_POLYNOMIAL
+ msb_mask = 1 << (data_len - 1)
+ crc_polynomial = crc_polynomial << (data_len - CRC_POLYNOMIAL_LENGTH)
+ for _ in range(data_len):
+ if data & msb_mask:
+ data = data ^ crc_polynomial
+ data = data << 1
+ return data >> (data_len - (CRC_POLYNOMIAL_LENGTH - 1))
+
+
+def diagnostic(device, args):
+ check_in_range('command_id', args.command_id)
+ check_in_range('command_data', args.data)
+ check_in_range('index', args.index)
+ check_in_range('preamble', args.preamble)
+
+ command = args.data
+ command = args.command_id | (command << FIELD_BITS['command_id'])
+ command = args.index | (command << FIELD_BITS['index'])
+
+ if args.crc is None:
+ crc = calculate_crc(command)
+ else:
+ crc = args.crc
+ check_in_range('crc', args.crc)
+
+ full_command = crc
+ full_command = command | (
+ full_command << (FIELD_BITS['command_data'] +
+ FIELD_BITS['command_id'] +
+ FIELD_BITS['index']))
+ full_command = args.preamble | (full_command << FIELD_BITS['preamble'])
+ command_bytes = full_command.to_bytes(8, byteorder='little')
+
+ report_id = find_feature_report_id(device.report_descriptor, 'diagnostic')
+ buf = array.array('B', [report_id] + list(command_bytes))
+
+ # Get a feature from the stylus to make sure it is paired.
+ get_feature(device, args.index, 'color')
+ fcntl.ioctl(device.device.fileno(), HIDIOCSFEATURE(len(buf)), buf)
+
+ return get_ioctl(device, buf)
+
+
+def format_bytes(buf):
+ """Format bytes for printing.
+
+ Given a little-endian list of bytes, format them for printing as a single
+ hex value.
+ """
+ ret = '0x'
+ for byte in reversed(buf):
+ ret += '{:02x}'.format(byte)
+ return ret
+
+
+def print_diagnostic(buf):
+ error_bit = (buf[3] & 0x40) >> 6
+ error_code = buf[3] & 0x3f
+ print('full response: {}'.format(format_bytes(buf[1:])))
+ print('error information available? {}'.format(error_bit))
+ print('error code: 0x{:02x}'.format(error_code))
+ print('command response: {}'.format(format_bytes(buf[1:3])))
+
+
+def print_feature(buf, feature_name):
+ # The first two elements of buf hold the report ID and stylus index,
+ # which do not need to be printed
+ if feature_name == 'color':
+ print('transducer color: {}'.format(buf[2]))
+ elif feature_name == 'width':
+ print('transducer width: {}'.format(buf[2]))
+ elif feature_name == 'style':
+ style = buf[2]
+ style_index = style - 1
+ if 0 <= style_index < len(STYLES):
+ style_name = STYLES[style_index]
+ else:
+ style_name = str(style)
+ print('transducer style: {}'.format(style_name))
+ elif feature_name == 'buttons':
+ for real_button_index in range(len(REAL_BUTTONS)):
+ real_button_name = REAL_BUTTONS[real_button_index]
+ virtual_button = buf[2 + real_button_index]
+ virtual_button_index = virtual_button - 1
+ if 0 <= virtual_button_index < len(ALL_BUTTONS):
+ virtual_button_name = ALL_BUTTONS[virtual_button_index]
+ else:
+ virtual_button_name = str(virtual_button)
+ print("'{}' maps to '{}'".format(real_button_name,
+ virtual_button_name))
+ elif feature_name == 'firmware':
+ print('vendor ID: {}'.format(format_bytes(buf[2:4])))
+ print('product ID: {}'.format(format_bytes(buf[4:12])))
+ print('firmware version: {}.{}'.format(buf[12], buf[13]))
+ elif feature_name == 'usi_version':
+ print('USI version: {}.{}'.format(buf[2], buf[3]))
+
+
+def set_feature(device, stylus_index, feature_name, value):
+ buf = get_feature(device, stylus_index, feature_name)
+ buf[2] = value
+ fcntl.ioctl(device.device.fileno(), HIDIOCSFEATURE(len(buf)), buf)
+
+
+def set_number_feature(device, stylus_index, feature_name, value):
+ try:
+ check_in_range(feature_name, value)
+ except ValueError as e:
+ log('ERROR: {}. Proceeding with setting other features.'.format(str(e)))
+ return
+ set_feature(device, stylus_index, feature_name, value)
+
+
+def set_style_feature(device, stylus_index, style_name):
+ style = STYLES.index(style_name) + 1
+ set_feature(device, stylus_index, 'style', style)
+
+
+def set_buttons_feature(device, stylus_index, mappings):
+ buf = get_feature(device, stylus_index, 'buttons')
+ base_offset = 2
+ for real_button_name, virtual_button_name in mappings:
+ offset = base_offset + REAL_BUTTONS.index(real_button_name)
+ virtual_button = ALL_BUTTONS.index(virtual_button_name) + 1
+ buf[offset] = virtual_button
+ fcntl.ioctl(device.device.fileno(), HIDIOCSFEATURE(len(buf)), buf)
+
+
+def set_features(device, args):
+ if args.color is not None:
+ set_number_feature(device, args.index, 'color', args.color)
+ if args.width is not None:
+ set_number_feature(device, args.index, 'width', args.width)
+ if args.style is not None:
+ set_style_feature(device, args.index, args.style)
+ if args.buttons is not None:
+ set_buttons_feature(device, args.index, args.buttons)
+
+
+def parse_arguments():
+ parser = argparse.ArgumentParser(
+ description='USI test tool',
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+ parser.add_argument(
+ '-p',
+ '--path',
+ default='/dev/hidraw0',
+ help='the path of the USI device')
+ subparsers = parser.add_subparsers(dest='command')
+
+ descriptor_help = 'print the report descriptor in human-readable format'
+ subparsers.add_parser(
+ 'descriptor', help=descriptor_help, description=descriptor_help)
+
+ data_help = 'print incoming data reports in human-readable format'
+ subparsers.add_parser('data', help=data_help, description=data_help)
+
+ parser_get = subparsers.add_parser(
+ 'get',
+ help='read a feature from a stylus',
+ description='Send a HID feature request to the device and print the '
+ 'response. Will wait for the specified stylus index to pair first.')
+ parser_get.add_argument(
+ 'feature',
+ choices=[
+ 'color', 'width', 'style', 'buttons', 'firmware', 'usi_version'
+ ],
+ help='which feature to read')
+ parser_get.add_argument(
+ '-i',
+ '--index',
+ default=1,
+ type=int,
+ help='which stylus to read values for, by index '
+ '(default: %(default)s)')
+
+ parser_set = subparsers.add_parser(
+ 'set',
+ help='change stylus features',
+ description='Set a stylus feature via HID feature report. Will wait '
+ 'for the specified stylus index to pair first.')
+ parser_set.add_argument(
+ '-i',
+ '--index',
+ default=1,
+ type=int,
+ help='which stylus to set values for, by index (default: %(default)s)')
+ parser_set.add_argument(
+ '-c',
+ '--color',
+ type=int,
+ metavar='[{},{}]'.format(0, field_max('color')),
+ help='set the stylus color to the specified value '
+ 'in the range ({},{})'.format(0, field_max('color')))
+ parser_set.add_argument(
+ '-w',
+ '--width',
+ type=int,
+ metavar='[{},{}]'.format(0, field_max('width')),
+ help='set the stylus width to the specified value '
+ 'in the range ({},{})'.format(0, field_max('width')))
+ parser_set.add_argument(
+ '-s',
+ '--style',
+ choices=STYLES,
+ help='set the stylus style to the specified value')
+ parser_set.add_argument(
+ '-b',
+ '--button',
+ dest='buttons',
+ nargs=2,
+ action='append',
+ metavar=('{%s}' % ','.join(REAL_BUTTONS),
+ '{%s}' % ','.join(ASSIGNABLE_BUTTONS)),
+ help='Remap a button to emulate another. '
+ 'For example: \'usi-test set -b barrel eraser\' will set the '
+ 'barrel button to act as an eraser button. You may set multiple '
+ 'buttons by setting this option multiple times.')
+
+ parser_diag = subparsers.add_parser(
+ 'diagnostic',
+ help='send a diagnostic command',
+ description='Send the specified diagnostic command to a stylus and '
+ 'read the response. You may set the preamble and cyclic redundancy '
+ 'check manually, but by default the tool will send the preamble and '
+ 'CRC defined by the USI spec.')
+ parser_diag.add_argument(
+ 'command_id', type=int, help='which diagnostic command to send')
+ parser_diag.add_argument(
+ 'data', type=int, help='the data to send with the diagnostic command')
+ parser_diag.add_argument(
+ '-i',
+ '--index',
+ default=1,
+ type=int,
+ help='which stylus to send command to, by index (default: %(default)s)')
+ parser_diag.add_argument(
+ '-c',
+ '--crc',
+ type=int,
+ help='the custom cyclic redundancy check for the diagnostic command. '
+ 'By default this tool will send the appropriate crc calculated by the '
+ 'algorithm defined in the USI spec.')
+ parser_diag.add_argument(
+ '-p',
+ '--preamble',
+ type=int,
+ default=DEFAULT_PREAMBLE,
+ help='the preamble for the diagnostic command. '
+ 'By default this tool will send the preamble defined in the USI spec')
+
+ args = parser.parse_args()
+
+ return args
+
+
+def main():
+ args = parse_arguments()
+
+ fd = open(args.path, 'rb+')
+ device = hidtools.hidraw.HidrawDevice(fd)
+
+ if args.command == 'descriptor':
+ device.dump(sys.stdout)
+ elif args.command == 'data':
+ while True:
+ device.dump(sys.stdout)
+ device.read_events()
+ elif args.command == 'get':
+ buf = get_feature(device, args.index, args.feature)
+ print_feature(buf, args.feature)
+ elif args.command == 'set':
+ set_features(device, args)
+ elif args.command == 'diagnostic':
+ buf = diagnostic(device, args)
+ print_diagnostic(buf)
+
+
+if __name__ == '__main__':
+ main()