Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

usb: Add high-level USB device support packages #558

Merged
merged 1 commit into from Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
136 changes: 136 additions & 0 deletions micropython/usb/README.md
@@ -0,0 +1,136 @@
# USB Drivers

These packages allow implementing USB functionality on a MicroPython system
using pure Python code.

Currently only USB device is implemented, not USB host.

## USB Device support

### Support

USB Device support depends on the low-level
[machine.USBDevice](https://docs.micropython.org/en/latest/library/machine.USBDevice.html)
class. This class is new and not supported on all ports, so please check the
documentation for your MicroPython version. It is possible to implement a USB
device using only the low-level USBDevice class. However, the packages here are
higher level and easier to use.

For more information about how to install packages, or "freeze" them into a
firmware image, consult the [MicroPython documentation on "Package
management"](https://docs.micropython.org/en/latest/reference/packages.html).

### Examples

The [examples/device](examples/device) directory in this repo has a range of
examples. After installing necessary packages, you can download an example and
run it with `mpremote run EXAMPLE_FILENAME.py` ([mpremote
docs](https://docs.micropython.org/en/latest/reference/mpremote.html#mpremote-command-run)).

#### Unexpected serial disconnects

If you normally connect to your MicroPython device over a USB serial port ("USB
CDC"), then running a USB example will disconnect mpremote when the new USB
device configuration activates and the serial port has to temporarily
disconnect. It is likely that mpremote will print an error. The example should
still start running, if necessary then you can reconnect with mpremote and type
Ctrl-B to restore the MicroPython REPL and/or Ctrl-C to stop the running
example.

If you use `mpremote run` again while a different USB device configuration is
already active, then the USB serial port may disconnect immediately before the
example runs. This is because mpremote has to soft-reset MicroPython, and when
the existing USB device is reset then the entire USB port needs to reset. If
this happens, run the same `mpremote run` command again.

We plan to add features to `mpremote` so that this limitation is less
disruptive. Other tools that communicate with MicroPython over the serial port
will encounter similar issues when runtime USB is in use.

### Initialising runtime USB

The overall pattern for enabling USB devices at runtime is:

1. Instantiate the Interface objects for your desired USB device.
2. Call `usb.device.get()` to get the singleton object for the high-level USB device.
3. Call `init(...)` to pass the desired interfaces as arguments, plus any custom
keyword arguments to configure the overall device.

An example, similar to [mouse_example.py](examples/device/mouse_example.py):

```py
m = usb.device.mouse.MouseInterface()
usb.device.get().init(m, builtin_driver=True)
```

Setting `builtin_driver=True` means that any built-in USB serial port will still
be available. Otherwise, you may permanently lose access to MicroPython until
the next time the device resets.

See [Unexpected serial disconnects](#Unexpected-serial-disconnects), above, for
an explanation of possible errors or disconnects when the runtime USB device
initialises.

Placing the call to `usb.device.get().init()` into the `boot.py` of the
MicroPython file system allows the runtime USB device to initialise immediately
on boot, before any built-in USB. This is a feature (not a bug) and allows you
full control over the USB device, for example to only enable USB HID and prevent
REPL access to the system.

However, note that calling this function on boot without `builtin_driver=True`
will make the MicroPython USB serial interface permanently inaccessible until
you "safe mode boot" (on supported boards) or completely erase the flash of your
device.

### Package usb-device

This base package contains the common implementation components for the other
packages, and can be used to implement new and different USB interface support.
All of the other `usb-device-<name>` packages depend on this package, and it
will be automatically installed as needed.

Specicially, this package provides the `usb.device.get()` function for accessing
the Device singleton object, and the `usb.device.core` module which contains the
low-level classes and utility functions for implementing new USB interface
drivers in Python. The best examples of how to use the core classes is the
source code of the other USB device packages.

### Package usb-device-keyboard

This package provides the `usb.device.keyboard` module. See
[keyboard_example.py](examples/device/keyboard_example.py) for an example
program.

### Package usb-device-mouse

This package provides the `usb.device.mouse` module. See
[mouse_example.py](examples/device/mouse_example.py) for an example program.

### Package usb-device-hid

This package provides the `usb.device.hid` module. USB HID (Human Interface
Device) class allows creating a wide variety of device types. The most common
are mouse and keyboard, which have their own packages in micropython-lib.
However, using the usb-device-hid package directly allows creation of any kind
of HID device.

See [hid_custom_keypad_example.py](examples/device/hid_custom_keypad_example.py)
for an example of a Keypad HID device with a custom HID descriptor.

### Package usb-device-cdc

This package provides the `usb.device.cdc` module. USB CDC (Communications
Device Class) is most commonly used for virtual serial port USB interfaces, and
that is what is supported here.

The example [cdc_repl_example.py](examples/device/cdc_repl_example.py)
demonstrates how to add a second USB serial interface and duplicate the
MicroPython REPL between the two.

### Package usb-device-midi

This package provides the `usb.device.midi` module. This allows implementing
USB MIDI devices in MicroPython.

The example [midi_example.py](examples/device/midi_example.py) demonstrates how
to create a simple MIDI device to send MIDI data to and from the USB host.
47 changes: 47 additions & 0 deletions micropython/usb/examples/device/cdc_repl_example.py
@@ -0,0 +1,47 @@
# MicroPython USB CDC REPL example
#
# Example demonstrating how to use os.dupterm() to provide the
# MicroPython REPL on a dynamic CDCInterface() serial port.
#
# To run this example:
#
# 1. Make sure `usb-device-cdc` is installed via: mpremote mip install usb-device-cdc
#
# 2. Run the example via: mpremote run cdc_repl_example.py
#
# 3. mpremote will exit with an error after the previous step, because when the
# example runs the existing USB device disconnects and then re-enumerates with
# the second serial port. If you check (for example by running mpremote connect
# list) then you should now see two USB serial devices.
#
# 4. Connect to one of the new ports: mpremote connect PORTNAME
#
# It may be necessary to type Ctrl-B to exit the raw REPL mode and resume the
# interactive REPL after mpremote connects.
#
# MIT license; Copyright (c) 2023-2024 Angus Gratton
import os
import time
import usb.device
from usb.device.cdc import CDCInterface

cdc = CDCInterface()
cdc.init(timeout=0) # zero timeout makes this non-blocking, suitable for os.dupterm()

# pass builtin_driver=True so that we get the built-in USB-CDC alongside,
# if it's available.
usb.device.get().init(cdc, builtin_driver=True)

print("Waiting for USB host to configure the interface...")

# wait for host enumerate as a CDC device...
while not cdc.is_open():
time.sleep_ms(100)

# Note: This example doesn't wait for the host to access the new CDC port,
# which could be done by polling cdc.dtr, as this will block the REPL
# from resuming while this code is still executing.

print("CDC port enumerated, duplicating REPL...")

old_term = os.dupterm(cdc)
144 changes: 144 additions & 0 deletions micropython/usb/examples/device/hid_custom_keypad_example.py
@@ -0,0 +1,144 @@
# MicroPython USB HID custom Keypad example
#
# This example demonstrates creating a custom HID device with its own
# HID descriptor, in this case for a USB number keypad.
#
# For higher level examples that require less code to use, see mouse_example.py
# and keyboard_example.py
#
# To run this example:
#
# 1. Make sure `usb-device-hid` is installed via: mpremote mip install usb-device-hid
#
# 2. Run the example via: mpremote run hid_custom_keypad_example.py
#
# 3. mpremote will exit with an error after the previous step, because when the
# example runs the existing USB device disconnects and then re-enumerates with
# the custom HID interface present. At this point, the example is running.
#
# 4. To see output from the example, re-connect: mpremote connect PORTNAME
#
# MIT license; Copyright (c) 2023 Dave Wickham, 2023-2024 Angus Gratton
from micropython import const
import time
import usb.device
from usb.device.hid import HIDInterface

_INTERFACE_PROTOCOL_KEYBOARD = const(0x01)


def keypad_example():
k = KeypadInterface()

usb.device.get().init(k, builtin_driver=True)

while not k.is_open():
time.sleep_ms(100)

while True:
time.sleep(2)
print("Press NumLock...")
k.send_key("<NumLock>")
time.sleep_ms(100)
k.send_key()
time.sleep(1)
# continue
print("Press ...")
for _ in range(3):
time.sleep(0.1)
k.send_key(".")
time.sleep(0.1)
k.send_key()
print("Starting again...")


class KeypadInterface(HIDInterface):
# Very basic synchronous USB keypad HID interface

def __init__(self):
super().__init__(
_KEYPAD_REPORT_DESC,
set_report_buf=bytearray(1),
protocol=_INTERFACE_PROTOCOL_KEYBOARD,
interface_str="MicroPython Keypad",
)
self.numlock = False

def on_set_report(self, report_data, _report_id, _report_type):
report = report_data[0]
b = bool(report & 1)
if b != self.numlock:
print("Numlock: ", b)
self.numlock = b

def send_key(self, key=None):
if key is None:
self.send_report(b"\x00")
else:
self.send_report(_key_to_id(key).to_bytes(1, "big"))


# See HID Usages and Descriptions 1.4, section 10 Keyboard/Keypad Page (0x07)
#
# This keypad example has a contiguous series of keys (KEYPAD_KEY_IDS) starting
# from the NumLock/Clear keypad key (0x53), but you can send any Key IDs from
# the table in the HID Usages specification.
_KEYPAD_KEY_OFFS = const(0x53)

_KEYPAD_KEY_IDS = [
"<NumLock>",
"/",
"*",
"-",
"+",
"<Enter>",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"0",
".",
]


def _key_to_id(key):
# This is a little slower than making a dict for lookup, but uses
# less memory and O(n) can be fast enough when n is small.
return _KEYPAD_KEY_IDS.index(key) + _KEYPAD_KEY_OFFS


# HID Report descriptor for a numeric keypad
#
# fmt: off
_KEYPAD_REPORT_DESC = (
b'\x05\x01' # Usage Page (Generic Desktop)
b'\x09\x07' # Usage (Keypad)
b'\xA1\x01' # Collection (Application)
b'\x05\x07' # Usage Page (Keypad)
b'\x19\x00' # Usage Minimum (0)
b'\x29\xFF' # Usage Maximum (ff)
b'\x15\x00' # Logical Minimum (0)
b'\x25\xFF' # Logical Maximum (ff)
b'\x95\x01' # Report Count (1),
b'\x75\x08' # Report Size (8),
b'\x81\x00' # Input (Data, Array, Absolute)
b'\x05\x08' # Usage page (LEDs)
b'\x19\x01' # Usage Minimum (1)
b'\x29\x01' # Usage Maximum (1),
b'\x95\x01' # Report Count (1),
b'\x75\x01' # Report Size (1),
b'\x91\x02' # Output (Data, Variable, Absolute)
b'\x95\x01' # Report Count (1),
b'\x75\x07' # Report Size (7),
b'\x91\x01' # Output (Constant) - padding bits
b'\xC0' # End Collection
)
# fmt: on


keypad_example()