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()