diff --git a/micropython/usbd/__init__.py b/micropython/usbd/__init__.py new file mode 100644 index 000000000..90739c27e --- /dev/null +++ b/micropython/usbd/__init__.py @@ -0,0 +1,4 @@ +from .device import get_usbdevice, USBInterface +from .hid import HIDInterface, MouseInterface +from .midi import DummyAudioInterface, MIDIInterface, MidiUSB +from . import utils diff --git a/micropython/usbd/cdc.py b/micropython/usbd/cdc.py new file mode 100644 index 000000000..0cc958a24 --- /dev/null +++ b/micropython/usbd/cdc.py @@ -0,0 +1,231 @@ +# MicroPython USB CDC module +# MIT license; Copyright (c) 2022 Martin Fischer +from .device import ( + USBInterface, + get_usbdevice +) +from .utils import ( + endpoint_descriptor, + split_bmRequestType, + STAGE_SETUP, + STAGE_DATA, + STAGE_ACK, + REQ_TYPE_STANDARD, + REQ_TYPE_CLASS, + EP_IN_FLAG +) +from micropython import const +import ustruct +import time + +_DEV_CLASS_MISC = const(0xef) +_CS_DESC_TYPE = const(0x24) # CS Interface type communication descriptor +_ITF_ASSOCIATION_DESC_TYPE = const(0xb) # Interface Association descriptor + +# CDC control interface definitions +_INTERFACE_CLASS_CDC = const(2) +_INTERFACE_SUBCLASS_CDC = const(2) # Abstract Control Mode +_PROTOCOL_NONE = const(0) # no protocol + +# CDC descriptor subtype +# see also CDC120.pdf, table 13 +_CDC_FUNC_DESC_HEADER = const(0) +_CDC_FUNC_DESC_CALL_MANAGEMENT = const(1) +_CDC_FUNC_DESC_ABSTRACT_CONTROL = const(2) +_CDC_FUNC_DESC_UNION = const(6) + +# CDC class requests, table 13, PSTN subclass +_SET_LINE_CODING_REQ = const(0x20) +_GET_LINE_CODING_REQ = const(0x21) +_SET_CONTROL_LINE_STATE = const(0x22) +_SEND_BREAK_REQ = const(0x23) + +_LINE_CODING_STOP_BIT_1 = const(0) +_LINE_CODING_STOP_BIT_1_5 = const(1) +_LINE_CODING_STOP_BIT_2 = const(2) + + +_LINE_CODING_PARITY_NONE = const(0) +_LINE_CODING_PARITY_ODD = const(1) +_LINE_CODING_PARITY_EVEN = const(2) +_LINE_CODING_PARITY_MARK = const(3) +_LINE_CODING_PARITY_SPACE = const(4) + +parity_bits_repr = ['N', 'O', 'E', 'M', 'S'] +stop_bits_repr = ['1', '1.5', '2'] + +# Other definitions +_CDC_VERSION = const(0x0120) # release number in binary-coded decimal + + +# CDC data interface definitions +_CDC_ITF_DATA_CLASS = const(0xa) +_CDC_ITF_DATA_SUBCLASS = const(0) +_CDC_ITF_DATA_PROT = const(0) # no protocol + + +def setup_CDC_device(): + # CDC is a composite device, consisting of multiple interfaces + # (CDC control and CDC data) + # therefore we have to make sure that the association descriptor + # is set and that it associates both interfaces to the logical cdc class + usb_device = get_usbdevice() + usb_device.device_class = _DEV_CLASS_MISC + usb_device.device_subclass = 2 + usb_device.device_protocol = 1 # Itf association descriptor + + +class CDCControlInterface(USBInterface): + # Implements the CDC Control Interface + + def __init__(self, _): + super().__init__(_INTERFACE_CLASS_CDC, _INTERFACE_SUBCLASS_CDC, _PROTOCOL_NONE) + self.rts = None + self.dtr = None + self.baudrate = None + self.stop_bits = 0 + self.parity = 0 + self.data_bits = None + self.break_cb = None # callback for break condition + + self.line_coding_state = bytearray(7) + + def get_itf_descriptor(self, num_eps, itf_idx, str_idx): + # CDC needs a Interface Association Descriptor (IAD) + # two interfaces in total + desc = ustruct.pack("= nbytes: + break + time.sleep_ms(10) # XXX blocking.. could be async'd + return self.total_rx + + def _cb_rx(self, ep, res, num_bytes): + self.total_rx.extend(self.mv_buf[:num_bytes]) + self.rx_nbytes += num_bytes + if self.rx_nbytes < self.rx_nbytes_requested: + # try to get more from endpoint + self._poll_rx_endpoint(self._cb_rx) diff --git a/micropython/usbd/cdc_example.py b/micropython/usbd/cdc_example.py new file mode 100644 index 000000000..f2edc91c8 --- /dev/null +++ b/micropython/usbd/cdc_example.py @@ -0,0 +1,14 @@ +from usbd import device, cdc + +ud = device.get_usbdevice() +cdc.setup_CDC_device() +ctrl_cdc = cdc.CDCControlInterface('') +data_cdc = cdc.CDCDataInterface('') +ud.add_interface(ctrl_cdc) +ud.add_interface(data_cdc) +ud.reenumerate() + +# sending something over CDC +data_cdc.write(b'Hello World') +# receiving something.. +print(data_cdc.read(10)) diff --git a/micropython/usbd/device.py b/micropython/usbd/device.py new file mode 100644 index 000000000..492b42ea3 --- /dev/null +++ b/micropython/usbd/device.py @@ -0,0 +1,625 @@ +# MicroPython USB device module +# MIT license; Copyright (c) 2022 Angus Gratton +from micropython import const +import machine +import struct + +from .utils import split_bmRequestType, EP_IN_FLAG + +# USB descriptor types +_STD_DESC_DEVICE_TYPE = const(0x1) +_STD_DESC_CONFIG_TYPE = const(0x2) +_STD_DESC_STRING_TYPE = const(0x3) +_STD_DESC_INTERFACE_TYPE = const(0x4) +_STD_DESC_INTERFACE_ASSOC = const(0xB) + +# Standard USB descriptor lengths +_STD_DESC_CONFIG_LEN = const(9) +_STD_DESC_INTERFACE_LEN = const(9) + +# Standard control request bmRequest fields, can extract by calling split_bmRequestType() +_REQ_RECIPIENT_DEVICE = const(0x0) +_REQ_RECIPIENT_INTERFACE = const(0x1) +_REQ_RECIPIENT_ENDPOINT = const(0x2) +_REQ_RECIPIENT_OTHER = const(0x3) + +# Offsets into the standard configuration descriptor, to fixup +_OFFS_CONFIG_iConfiguration = const(6) + + +# Singleton _USBDevice instance +_inst = None + + +def get_usbdevice(): + # Access the singleton instance of the MicroPython _USBDevice object. + # + # TODO: It might be better to factor this as a module-level interface? + global _inst + if not _inst: + _inst = _USBDevice() + return _inst + + +class _USBDevice: + # Class that implements the Python parts of the MicroPython USBDevice. + # + # This object represents any interfaces on the USB device that are implemented + # in Python, and also allows disabling the 'static' USB interfaces that are + # implemented in Python (if include_static property is set to False). + # + # Should be accessed via the singleton getter module function get_usbdevice(), + # not instantiated directly.. + def __init__(self): + self._eps = {} # Mapping from endpoint address to interface object + self._ep_cbs = {} # Mapping from endpoint address to Optional[xfer callback] + self._itfs = [] # List of interfaces + self.include_static = True # Include static devices when enumerating? + + # Device properties, set non-NULL to override static values + self.manufacturer_str = None + self.product_str = None + self.serial_str = None + self.id_vendor = None + self.id_product = None + self.device_class = None + self.device_subclass = None + self.device_protocol = None + self.bcd_device = None + + # Configuration properties + self.config_str = None + self.max_power_ma = 50 + + self._strs = self._get_device_strs() + + usbd = self._usbd = machine.USBD() + usbd.init( + descriptor_device_cb=self._descriptor_device_cb, + descriptor_config_cb=self._descriptor_config_cb, + descriptor_string_cb=self._descriptor_string_cb, + open_cb=self._open_cb, + reset_cb=self._reset_cb, + control_xfer_cb=self._control_xfer_cb, + xfer_cb=self._xfer_cb, + ) + + def add_interface(self, itf): + # Add an instance of USBInterface to the USBDevice. + # + # The next time USB is reenumerated (by calling .reenumerate() or + # otherwise), this interface will appear to the host. + self._itfs.append(itf) + + def remove_interface(self, itf): + # Remove an instance of USBInterface from the USBDevice. + # + # If the USB device is currently enumerated to a host, and in particular + # if any endpoint transfers are pending, then this may cause it to + # misbehave as these transfers are not cancelled. + self._itfs.remove(itf) + + def reenumerate(self): + # Disconnect the USB device and then reconnect it, causing the host to + # reenumerate it. + # + # Any open USB interfaces (for example USB-CDC serial connection) will be + # temporarily terminated. + # + # This is the only way to change the composition of an existing USB device + # from the device side without disconnecting/reconnecting the port. + self._usbd.reenumerate() + + def _descriptor_device_cb(self): + # Singleton callback from TinyUSB to read the USB device descriptor. + # + # This function will build a new device descriptor based on the 'static' + # USB device values compiled into MicroPython, but many values can be + # optionally overriden by setting properties of this object. + + FMT = "= 0 # index shouldn't be in the static range + try: + return self._itfs[index] + except IndexError: + return None # host has old mappings for interfaces + + def _descriptor_config_cb(self): + # Singleton callback from TinyUSB to read the configuration descriptor. + # + # Each time this function is called (in response to a GET DESCRIPTOR - + # CONFIGURATION request from the host), it rebuilds the full configuration + # descriptor and also the list of strings stored in self._strs. + # + # This normally only happens during enumeration, but may happen more than + # once (the host will first ask for a minimum length descriptor, and then + # use the length field request to request the whole thing). + static = self._usbd.static + + # Rebuild the _strs list as we build the configuration descriptor + strs = self._get_device_strs() + + if self.include_static: + desc = bytearray(static.desc_cfg) + else: + desc = bytearray(_STD_DESC_CONFIG_LEN) + + self._eps = {} # rebuild endpoint mapping as we enumerate each interface + self._ep_cbs = {} + itf_idx = static.itf_max + ep_addr = static.ep_max + str_idx = static.str_max + len(strs) + for itf in self._itfs: + # Get the endpoint descriptors first so we know how many endpoints there are + ep_desc, ep_strs, ep_addrs = itf.get_endpoint_descriptors(ep_addr, str_idx) + strs += ep_strs + str_idx += len(ep_strs) + + # Now go back and get the interface descriptor + itf_desc, itf_strs = itf.get_itf_descriptor(len(ep_addrs), itf_idx, str_idx) + desc += itf_desc + strs += itf_strs + itf_idx += 1 + str_idx += len(itf_strs) + + desc += ep_desc + for e in ep_addrs: + self._eps[e] = itf + self._ep_cbs[e] = None # no pending callback + # TODO: check if always incrementing leaves too many gaps + ep_addr = max((e & ~EP_IN_FLAG) + 1, ep_addr) + + self._update_configuration_descriptor(desc) + + self._strs = strs + return desc + + def _update_configuration_descriptor(self, desc): + # Utility function to update the Standard Configuration Descriptor + # header supplied in the argument with values based on the current state + # of the device. + # + # See USB 2.0 specification section 9.6.3 p264 for details. + # + # Currently only one configuration per device is supported. + bmAttributes = ( + (1 << 7) # Reserved + | (0 if self.max_power_ma else (1 << 6)) # Self-Powered + # Remote Wakeup not currently supported + ) + + iConfiguration = self._get_str_index(self.config_str) + if self.include_static and not iConfiguration: + iConfiguration = desc[_OFFS_CONFIG_iConfiguration] + + bNumInterfaces = self._usbd.static.itf_max if self.include_static else 0 + bNumInterfaces += len(self._itfs) + + struct.pack_into( + "= 0 + ) # Shouldn't get any calls here where index is less than first dynamic string index + try: + return self._strs[index] + except IndexError: + return None + + def _open_cb(self, interface_desc_view): + # Singleton callback from TinyUSB custom class driver, when USB host does + # Set Configuration. The "runtime class device" accepts all interfaces that + # it has sent in descriptors, and calls this callback. + + # Walk the view of the "claimed" descriptor data provided in the + # callback and call handle_open() on each claimed interface + # + # ... this may be unnecessary at the moment, as only one configuration is supported so we + # can probably assume all the interfaces will be included. + i = 0 + while i < len(interface_desc_view): + # descriptor length, type, and index (if it's an interface descriptor) + dl, dt, di = interface_desc_view[i : i + 3] + if dt == _STD_DESC_INTERFACE_TYPE: + if di >= self._usbd.static.itf_max: + di -= self._usbd.static.itf_max + self._itfs[di].handle_open() + i += dl + assert dl + + def _reset_cb(self): + # Callback when the USB device is reset by the host + + # Cancel outstanding transfer callbacks + for k in self._ep_cbs.keys(): + self._ep_cbs[k] = None + + # Allow interfaces to respond to the reset + for itf in self._itfs: + itf.handle_reset() + + def _submit_xfer(self, ep_addr, data, done_cb=None): + # Singleton function to submit a USB transfer (of any type except control). + # + # Generally, drivers should call USBInterface.submit_xfer() instead. See + # that function for documentation about the possible parameter values. + cb = self._ep_cbs[ep_addr] + if cb: + raise RuntimeError(f"Pending xfer on EP {ep_addr}") + + # USBD callback may be called immediately, before Python execution + # continues + self._ep_cbs[ep_addr] = done_cb + + if not self._usbd.submit_xfer(ep_addr, data): + self._ep_cbs[ep_addr] = None + return False + return True + + def _xfer_cb(self, ep_addr, result, xferred_bytes): + # Singleton callback from TinyUSB custom class driver when a transfer completes. + cb = self._ep_cbs.get(ep_addr, None) + if cb: + self._ep_cbs[ep_addr] = None + cb(ep_addr, result, xferred_bytes) + + def _control_xfer_cb(self, stage, request): + # Singleton callback from TinyUSB custom class driver when a control + # transfer is in progress. + # + # stage determines appropriate responses (possible values + # utils.STAGE_SETUP, utils.STAGE_DATA, utils.STAGE_ACK). + # + # The TinyUSB class driver framework only calls this function for + # particular types of control transfer, other standard control transfers + # are handled by TinyUSB itself. + bmRequestType, _, _, wIndex, _ = request + recipient, _, _ = split_bmRequestType(bmRequestType) + + itf = None + result = None + + if recipient == _REQ_RECIPIENT_DEVICE: + itf = self._get_interface(wIndex & 0xFFFF) + if itf: + result = itf.handle_device_control_xfer(stage, request) + elif recipient == _REQ_RECIPIENT_INTERFACE: + itf = self._get_interface(wIndex & 0xFFFF) + if itf: + result = itf.handle_interface_control_xfer(stage, request) + elif recipient == _REQ_RECIPIENT_ENDPOINT: + ep_num = wIndex & 0xFFFF + itf = self._eps.get(ep_num, None) + if itf: + result = itf.handle_endpoint_control_xfer(stage, request) + + if not itf: + # At time this code was written, only the control transfers shown + # above are passed to the class driver callback. See + # invoke_class_control() in tinyusb usbd.c + print(f"Unexpected control request type {bmRequestType:#x}") + return False + + # Accept the following possible replies from handle_NNN_control_xfer(): + # + # True - Continue transfer, no data + # False - STALL transfer + # Object with buffer interface - submit this data for the control transfer + if type(result) == bool: + return result + + return self._usbd.control_xfer(request, result) + + +class USBInterface: + # Abstract base class to implement a USBInterface (and associated endpoints) in Python + + def __init__( + self, + bInterfaceClass=0xFF, + bInterfaceSubClass=0, + bInterfaceProtocol=0xFF, + interface_str=None, + ): + # Create a new USBInterface object. Optionally can set bInterfaceClass, + # bInterfaceSubClass, bInterfaceProtocol values to specify the interface + # type. Can also optionally set a string descriptor value interface_str to describe this + # interface. + # + # The defaults are to set 'vendor' class and protocol values, the host + # will not attempt to use any standard class driver to talk to this + # interface. + + # Defaults set "vendor" class and protocol + self.bInterfaceClass = bInterfaceClass + self.bInterfaceSubClass = bInterfaceSubClass + self.bInterfaceProtocol = bInterfaceProtocol + self.interface_str = interface_str + self._open = False + + def get_itf_descriptor(self, num_eps, itf_idx, str_idx): + # Return the interface descriptor binary data and associated other + # descriptors for the interface (not including endpoint descriptors), plus + # associated string descriptor data. + # + # For most types of USB interface, this function doesn't need to be + # overriden. Only override if you need to append interface-specific + # descriptors before the first endpoint descriptor. To return an Interface + # Descriptor Association, on the first interface this function should + # return the IAD descriptor followed by the Interface descriptor. + # + # Parameters: + # + # - num_eps - number of endpoints in the interface, as returned by + # get_endpoint_descriptors() which is actually called before this + # function. + # + # - itf_idx - Interface index number for this interface. + # + # - str_idx - First string index number to assign for any string + # descriptor indexes included in the result. + # + # Result: + # + # Should be a 2-tuple: + # + # - Interface descriptor binary data, to return as part of the + # configuration descriptor. + # + # - List of any strings referenced in the interface descriptor data + # (indexes in the descriptor data should start from 'str_idx'.) + # + # See USB 2.0 specification section 9.6.5 p267 for standard interface descriptors. + desc = struct.pack( + "<" + "B" * _STD_DESC_INTERFACE_LEN, + _STD_DESC_INTERFACE_LEN, # bLength + _STD_DESC_INTERFACE_TYPE, # bDescriptorType + itf_idx, # bInterfaceNumber + 0, # bAlternateSetting, not currently supported + num_eps, + self.bInterfaceClass, + self.bInterfaceSubClass, + self.bInterfaceProtocol, + str_idx if self.interface_str else 0, # iInterface + ) + strs = [self.interface_str] if self.interface_str else [] + + return (desc, strs) + + def get_endpoint_descriptors(self, ep_addr, str_idx): + # Similar to get_itf_descriptor, returns descriptors for any endpoints + # in this interface, plus associated other configuration descriptor data. + # + # The base class returns no endpoints, so usually this is overriden in the subclass. + # + # This function is called any time the host asks for a configuration + # descriptor. It is actually called before get_itf_descriptor(), so that + # the number of endpoints is known. + # + # Parameters: + # + # - ep_addr - Address for this endpoint, without any utils.EP_IN_FLAG (0x80) bit set. + # - str_idx - Index to use for the first string descriptor in the result, if any. + # + # Result: + # + # Should be a 3-tuple: + # + # - Endpoint descriptor binary data and associated other descriptors for + # the endpoint, to return as part of the configuration descriptor. + # + # - List of any strings referenced in the descriptor data (indexes in the + # descriptor data should start from 'str_idx'.) + # + # - List of endpoint addresses referenced in the descriptor data (should + # start from ep_addr, optionally with the utils.EP_IN_FLAG bit set.) + return (b"", [], []) + + def handle_open(self): + # Callback called when the USB host accepts the device configuration. + # + # Override this function to initiate any operations that the USB interface + # should do when the USB device is configured to the host. + self._open = True + + def handle_reset(self): + # Callback called on every registered interface when the USB device is + # reset by the host. This can happen when the USB device is unplugged, + # or if the host triggers a reset for some other reason. + # + # Override this function to cancel any pending operations specific to + # the interface (outstanding USB transfers are already cancelled). + # + # At this point, no USB functionality is available - handle_open() will + # be called later if/when the USB host re-enumerates and configures the + # interface. + self._open = False + + def is_open(self): + # Returns True if the interface is in use + return self._open + + def handle_device_control_xfer(self, stage, request): + # Control transfer callback. Override to handle a non-standard device + # control transfer where bmRequestType Recipient is Device, Type is + # utils.REQ_TYPE_CLASS, and the lower byte of wIndex indicates this interface. + # + # (See USB 2.0 specification 9.4 Standard Device Requests, p250). + # + # This particular request type seems pretty uncommon for a device class + # driver to need to handle, most hosts will not send this so most + # implementations won't need to override it. + # + # Parameters: + # + # - stage is one of utils.STAGE_SETUP, utils.STAGE_DATA, utils.STAGE_ACK. + # - request is a tuple of (bmRequestType, bRequest, wValue, wIndex, + # - wLength), as per USB 2.0 specification 9.3 USB Device Requests, p250. + # + # The function can call split_bmRequestType() to split bmRequestType into + # (Recipient, Type, Direction). + # + # Result, any of: + # + # - True to continue the request, False to STALL the endpoint. + # - Buffer interface object to provide a buffer to the host as part of the + # transfer, if possible. + return False + + def handle_interface_control_xfer(self, stage, request): + # Control transfer callback. Override to handle a device control + # transfer where bmRequestType Recipient is Interface, and the lower byte + # of wIndex indicates this interface. + # + # (See USB 2.0 specification 9.4 Standard Device Requests, p250). + # + # bmRequestType Type field may have different values. It's not necessary + # to handle the mandatory Standard requests (bmRequestType Type == + # utils.REQ_TYPE_STANDARD), if the driver returns False in these cases then + # TinyUSB will provide the necessary responses. + # + # See handle_device_control_xfer() for a description of the arguments and + # possible return values. + return False + + def handle_endpoint_control_xfer(self, stage, request): + # Control transfer callback. Override to handle a device + # control transfer where bmRequestType Recipient is Endpoint and + # the lower byte of wIndex indicates an endpoint address associated + # with this interface. + # + # bmRequestType Type will generally have any value except + # utils.REQ_TYPE_STANDARD, as Standard endpoint requests are handled by + # TinyUSB. The exception is the the Standard "Set Feature" request. This + # is handled by Tiny USB but also passed through to the driver in case it + # needs to change any internal state, but most drivers can ignore and + # return False in this case. + # + # (See USB 2.0 specification 9.4 Standard Device Requests, p250). + # + # See handle_device_control_xfer() for a description of the parameters and + # possible return values. + return False + + def submit_xfer(self, ep_addr, data, done_cb=None): + # Submit a USB transfer (of any type except control) + # + # Parameters: + # + # - ep_addr. Address of the endpoint to submit the transfer on. Caller is + # responsible for ensuring that ep_addr is correct and belongs to this + # interface. Only one transfer can be active at a time on each endpoint. + # + # - data. Buffer containing data to send, or for data to be read into + # (depending on endpoint direction). + # + # - done_cb. Optional callback function for when the transfer + # completes. The callback is called with arguments (ep_addr, result, + # xferred_bytes) where result is one of xfer_result_t enum (see top of + # this file), and xferred_bytes is an integer. + # + # Note that done_cb may be called immediately, possibly before this + # function has returned to the caller. + if not self._open: + raise RuntimeError + return get_usbdevice()._submit_xfer(ep_addr, data, done_cb) + + def set_ep_stall(self, ep_addr, stall): + # Set or clear endpoint STALL state, according to the bool "stall" parameter. + # + # Generally endpoint STALL is handled automatically by TinyUSB, but + # there are some device classes that need to explicitly stall or unstall + # an endpoint under certain conditions. + if not self._open or ep_addr not in get_usbdevice()._eps: + raise RuntimeError + get_usbdevice()._usbd.set_ep_stall(ep_addr, stall) + + def get_ep_stall(self, ep_addr): + # Get the current endpoint STALL state. + # + # Endpoint can be stalled/unstalled by host, TinyUSB stack, or calls to + # set_ep_stall(). + if not self._open or ep_addr not in get_usbdevice()._eps: + raise RuntimeError + return get_usbdevice()._usbd.get_ep_stall(ep_addr) diff --git a/micropython/usbd/hid.py b/micropython/usbd/hid.py new file mode 100644 index 000000000..dc2d86f84 --- /dev/null +++ b/micropython/usbd/hid.py @@ -0,0 +1,301 @@ +# MicroPython USB hid module +# MIT license; Copyright (c) 2023 Angus Gratton +from .device import ( + USBInterface, +) +from .utils import ( + endpoint_descriptor, + split_bmRequestType, + EP_IN_FLAG, + STAGE_SETUP, + STAGE_DATA, + REQ_TYPE_STANDARD, + REQ_TYPE_CLASS, +) +from micropython import const +import struct + +_DESC_HID_TYPE = const(0x21) +_DESC_REPORT_TYPE = const(0x22) +_DESC_PHYSICAL_TYPE = const(0x23) + +_INTERFACE_CLASS = const(0x03) +_INTERFACE_SUBCLASS_NONE = const(0x00) +_INTERFACE_SUBCLASS_BOOT = const(0x01) + +_INTERFACE_PROTOCOL_NONE = const(0x00) +_INTERFACE_PROTOCOL_KEYBOARD = const(0x01) +_INTERFACE_PROTOCOL_MOUSE = const(0x02) + +# bRequest values for HID control requests +_REQ_CONTROL_GET_REPORT = const(0x01) +_REQ_CONTROL_GET_IDLE = const(0x02) +_REQ_CONTROL_GET_PROTOCOL = const(0x03) +_REQ_CONTROL_GET_DESCRIPTOR = const(0x06) +_REQ_CONTROL_SET_REPORT = const(0x09) +_REQ_CONTROL_SET_IDLE = const(0x0A) +_REQ_CONTROL_SET_PROTOCOL = const(0x0B) + + +class HIDInterface(USBInterface): + # Abstract base class to implement a USB device HID interface in Python. + + def __init__( + self, + report_descriptor, + extra_descriptors=[], + set_report_buf=None, + protocol=_INTERFACE_PROTOCOL_NONE, + interface_str=None, + ): + # Construct a new HID interface. + # + # - report_descriptor is the only mandatory argument, which is the binary + # data consisting of the HID Report Descriptor. See Device Class + # Definition for Human Interface Devices (HID) v1.11 section 6.2.2 Report + # Descriptor, p23. + # + # - extra_descriptors is an optional argument holding additional HID + # descriptors, to append after the mandatory report descriptor. Most + # HID devices do not use these. + # + # - set_report_buf is an optional writable buffer object (i.e. + # bytearray), where SET_REPORT requests from the host can be + # written. Only necessary if the report_descriptor contains Output + # entries. If set, the size must be at least the size of the largest + # Output entry. + # + # - protocol can be set to a specific value as per HID v1.11 section 4.3 Protocols, p9. + # + # - interface_str is an optional string descriptor to associate with the HID USB interface. + super().__init__(_INTERFACE_CLASS, _INTERFACE_SUBCLASS_NONE, protocol, interface_str) + self.extra_descriptors = extra_descriptors + self.report_descriptor = report_descriptor + self._set_report_buf = set_report_buf + self._int_ep = None # set during enumeration + + def get_report(self): + return False + + def handle_set_report(self, report_data, report_id, report_type): + # Override this function in order to handle SET REPORT requests from the host, + # where it sends data to the HID device. + # + # This function will only be called if the Report descriptor contains at least one Output entry, + # and the set_report_buf argument is provided to the constructor. + # + # Return True to complete the control transfer normally, False to abort it. + return True + + def send_report(self, report_data): + # Helper function to send a HID report in the typical USB interrupt + # endpoint associated with a HID interface. return + self.submit_xfer(self._int_ep, report_data) + + def get_endpoint_descriptors(self, ep_addr, str_idx): + # Return the typical single USB interrupt endpoint descriptor associated + # with a HID interface. + # + # As per HID v1.11 section 7.1 Standard Requests, return the contents of + # the standard HID descriptor before the associated endpoint descriptor. + desc = self.get_hid_descriptor() + ep_addr |= EP_IN_FLAG + desc += endpoint_descriptor(ep_addr, "interrupt", 8, 8) + self.idle_rate = 0 + self.protocol = 0 + self._int_ep = ep_addr + return (desc, [], [ep_addr]) + + def get_hid_descriptor(self): + # Generate a full USB HID descriptor from the object's report descriptor + # and optional additional descriptors. + # + # See HID Specification Version 1.1, Section 6.2.1 HID Descriptor p22 + result = struct.pack( + "> 8 + if desc_type == _DESC_HID_TYPE: + return self.get_hid_descriptor() + if desc_type == _DESC_REPORT_TYPE: + return self.report_descriptor + elif req_type == REQ_TYPE_CLASS: + # HID Spec p50: 7.2 Class-Specific Requests + if bRequest == _REQ_CONTROL_GET_REPORT: + print("GET_REPORT?") + return False # Unsupported for now + if bRequest == _REQ_CONTROL_GET_IDLE: + return bytes([self.idle_rate]) + if bRequest == _REQ_CONTROL_GET_PROTOCOL: + return bytes([self.protocol]) + if bRequest == _REQ_CONTROL_SET_IDLE: + self.idle_rate = wValue >> 8 + return b"" + if bRequest == _REQ_CONTROL_SET_PROTOCOL: + self.protocol = wValue + return b"" + if bRequest == _REQ_CONTROL_SET_REPORT: + # Return the _set_report_buf to be filled with the + # report data + if not self._set_report_buf: + return False + elif wLength >= len(self._set_report_buf): + # Saves an allocation if the size is exactly right (or will be a short read) + return self._set_report_buf + else: + # Otherwise, need to wrap the buffer in a memoryview of the correct length + # + # TODO: check this is correct, maybe TinyUSB won't mind if we ask for more + # bytes than the host has offered us. + return memoryview(self._set_report_buf)[:wLength] + return False # Unsupported + + if stage == STAGE_DATA: + if req_type == REQ_TYPE_CLASS: + if bRequest == _REQ_CONTROL_SET_REPORT and self._set_report_buf: + report_id = wValue & 0xFF + report_type = wValue >> 8 + report_data = self._set_report_buf + if wLength < len(report_data): + # as above, need to truncate the buffer if we read less + # bytes than what was provided + report_data = memoryview(self._set_report_buf)[:wLength] + self.handle_set_report(report_data, report_id, report_type) + + return True # allow DATA/ACK stages to complete normally + + +# Basic 3-button mouse HID Report Descriptor. +# This is cribbed from Appendix E.10 of the HID v1.11 document. +_MOUSE_REPORT_DESC = bytes( + [ + 0x05, + 0x01, # Usage Page (Generic Desktop) + 0x09, + 0x02, # Usage (Mouse) + 0xA1, + 0x01, # Collection (Application) + 0x09, + 0x01, # Usage (Pointer) + 0xA1, + 0x00, # Collection (Physical) + 0x05, + 0x09, # Usage Page (Buttons) + 0x19, + 0x01, # Usage Minimum (01), + 0x29, + 0x03, # Usage Maximun (03), + 0x15, + 0x00, # Logical Minimum (0), + 0x25, + 0x01, # Logical Maximum (1), + 0x95, + 0x03, # Report Count (3), + 0x75, + 0x01, # Report Size (1), + 0x81, + 0x02, # Input (Data, Variable, Absolute), ;3 button bits + 0x95, + 0x01, # Report Count (1), + 0x75, + 0x05, # Report Size (5), + 0x81, + 0x01, # Input (Constant), ;5 bit padding + 0x05, + 0x01, # Usage Page (Generic Desktop), + 0x09, + 0x30, # Usage (X), + 0x09, + 0x31, # Usage (Y), + 0x15, + 0x81, # Logical Minimum (-127), + 0x25, + 0x7F, # Logical Maximum (127), + 0x75, + 0x08, # Report Size (8), + 0x95, + 0x02, # Report Count (2), + 0x81, + 0x06, # Input (Data, Variable, Relative), ;2 position bytes (X & Y) + 0xC0, # End Collection, + 0xC0, # End Collection + ] +) + + +class MouseInterface(HIDInterface): + # Very basic synchronous USB mouse HID interface + # TODO: This should be in a different package or an example + + def __init__(self): + super().__init__( + _MOUSE_REPORT_DESC, + protocol=_INTERFACE_PROTOCOL_MOUSE, + interface_str="MP Mouse!", + ) + self._l = False # Left button + self._m = False # Middle button + self._r = False # Right button + + def send_report(self, dx=0, dy=0): + b = 0 + if self._l: + b |= 1 << 0 + if self._r: + b |= 1 << 1 + if self._m: + b |= 1 << 2 + # Note: This allocates the bytes object 'report' each time a report is + # sent. + # + # However, at the moment the base class doesn't keep track of each + # transfer after it's submitted. So reusing a bytearray() creates a risk + # of a race condition if a new report transfer is submitted using the + # same buffer, before the previous one has completed. + report = struct.pack("Bbb", b, dx, dy) + + super().send_report(report) + + def click_left(self, down=True): + self._l = down + self.send_report() + + def click_middle(self, down=True): + self._m = down + self.send_report() + + def click_right(self, down=True): + self._r = down + self.send_report() + + def move_by(self, dx, dy): + # dx, dy are -127, 127 in range + self.send_report(dx, dy) diff --git a/micropython/usbd/hid_keypad.py b/micropython/usbd/hid_keypad.py new file mode 100644 index 000000000..9c5f4769a --- /dev/null +++ b/micropython/usbd/hid_keypad.py @@ -0,0 +1,96 @@ +# MicroPython USB keypad module +# MIT license; Copyright (c) 2023 Dave Wickham, Angus Gratton + +from .hid import HIDInterface +from micropython import const + +_INTERFACE_PROTOCOL_KEYBOARD = const(0x01) + +# 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 = [ + "", + "/", + "*", + "-", + "+", + "", + "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 + + +# fmt: off +_KEYPAD_REPORT_DESC = bytes( + [ + 0x05, 0x01, # Usage Page (Generic Desktop) + 0x09, 0x07, # Usage (Keypad) + 0xA1, 0x01, # Collection (Application) + 0x05, 0x07, # Usage Page (Keypad) + 0x19, 0x00, # Usage Minimum (0) + 0x29, 0xFF, # Usage Maximum (ff) + 0x15, 0x00, # Logical Minimum (0) + 0x25, 0xFF, # Logical Maximum (ff) + 0x95, 0x01, # Report Count (1), + 0x75, 0x08, # Report Size (8), + 0x81, 0x00, # Input (Data, Array, Absolute) + 0x05, 0x08, # Usage page (LEDs) + 0x19, 0x01, # Usage Minimum (1) + 0x29, 0x01, # Usage Maximum (1), + 0x95, 0x01, # Report Count (1), + 0x75, 0x01, # Report Size (1), + 0x91, 0x02, # Output (Data, Variable, Absolute) + 0x95, 0x01, # Report Count (1), + 0x75, 0x07, # Report Size (7), + 0x91, 0x01, # Output (Constant) - padding bits + 0xC0, # End Collection + ] +) +# fmt: on + + +class KeypadInterface(HIDInterface): + # Very basic synchronous USB keypad HID interface + + def __init__(self): + self.numlock = False + self.set_report_initialised = False + super().__init__( + _KEYPAD_REPORT_DESC, + set_report_buf=bytearray(1), + protocol=_INTERFACE_PROTOCOL_KEYBOARD, + interface_str="MicroPython Keypad", + ) + + def handle_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")) diff --git a/micropython/usbd/manifest.py b/micropython/usbd/manifest.py new file mode 100644 index 000000000..78b2c69fb --- /dev/null +++ b/micropython/usbd/manifest.py @@ -0,0 +1,15 @@ +metadata(version="0.1.0") + +# TODO: split this up into sub-packages, and some code in example subdirectory +package( + "usbd", + files=( + "__init__.py", + "device.py", + "hid.py", + "hid_keypad.py", + "midi.py", + "utils.py", + ), + base_path="..", +) diff --git a/micropython/usbd/midi.py b/micropython/usbd/midi.py new file mode 100644 index 000000000..4e2243716 --- /dev/null +++ b/micropython/usbd/midi.py @@ -0,0 +1,306 @@ +# MicroPython USB MIDI module +# MIT license; Copyright (c) 2023 Angus Gratton, Paul Hamshere +from micropython import const +import struct + +from .device import USBInterface +from .utils import endpoint_descriptor, EP_IN_FLAG + +_INTERFACE_CLASS_AUDIO = const(0x01) +_INTERFACE_SUBCLASS_AUDIO_CONTROL = const(0x01) +_INTERFACE_SUBCLASS_AUDIO_MIDISTREAMING = const(0x03) +_PROTOCOL_NONE = const(0x00) + +_JACK_TYPE_EMBEDDED = const(0x01) +_JACK_TYPE_EXTERNAL = const(0x02) + + +class RingBuf: + def __init__(self, size): + self.data = bytearray(size) + self.size = size + self.index_put = 0 + self.index_get = 0 + + def put(self, value): + next_index = (self.index_put + 1) % self.size + # check for overflow + if self.index_get != next_index: + self.data[self.index_put] = value + self.index_put = next_index + return value + else: + return None + + def get(self): + if self.index_get == self.index_put: + return None # buffer empty + else: + value = self.data[self.index_get] + self.index_get = (self.index_get + 1) % self.size + return value + + def is_empty(self): + return self.index_get == self.index_put + + +class DummyAudioInterface(USBInterface): + # An Audio Class interface is mandatory for MIDI Interfaces as well, this + # class implements the minimum necessary for this. + def __init__(self): + super().__init__(_INTERFACE_CLASS_AUDIO, _INTERFACE_SUBCLASS_AUDIO_CONTROL, _PROTOCOL_NONE) + + def get_itf_descriptor(self, num_eps, itf_idx, str_idx): + # Return the MIDI USB interface descriptors. + + # Get the parent interface class + desc, strs = super().get_itf_descriptor(num_eps, itf_idx, str_idx) + + # Append the class-specific AudioControl interface descriptor + desc += struct.pack( + "Device) + # * Data goes via an Embedded MIDI IN Jack ("into" the USB-MIDI device) + # * Data goes out via a virtual External MIDI OUT Jack ("out" of the + # USB-MIDI device and into the world). This "out" jack may be + # theoretical, and only exists in the USB descriptor. + # + # - For each tx (total _num_tx), we have data flowing from the USB MIDI + # device to the USB host: + # * Data comes in via a virtual External MIDI IN Jack (from the + # outside world, theoretically) + # * Data goes via an Embedded MIDI OUT Jack ("out" of the USB-MIDI + # device). + # * Data goes into the host via MIDI IN Endpoint (Device->Host) + + # rx side + for idx in range(self._num_rx): + emb_id = self._emb_id(False, idx) + ext_id = emb_id + 1 + pin = idx + 1 + jacks += jack_in_desc(_JACK_TYPE_EMBEDDED, emb_id) # bJackID) + jacks += jack_out_desc( + _JACK_TYPE_EXTERNAL, + ext_id, # bJackID + emb_id, # baSourceID(1) + pin, # baSourcePin(1) + ) + + # tx side + for idx in range(self._num_tx): + emb_id = self._emb_id(True, idx) + ext_id = emb_id + 1 + pin = idx + 1 + + jacks += jack_in_desc( + _JACK_TYPE_EXTERNAL, + ext_id, # bJackID + ) + jacks += jack_out_desc( + _JACK_TYPE_EMBEDDED, + emb_id, + ext_id, # baSourceID(1) + pin, # baSourcePin(1) + ) + + iface = desc + cs_ms_interface + jacks + return (iface, strs) + + def _emb_id(self, is_tx, idx): + # Given a direction (False==rx, True==tx) and a 0-index + # of the MIDI connection, return the embedded JackID value. + # + # Embedded JackIDs take odd numbers 1,3,5,etc with all + # 'RX' jack numbers first and then all 'TX' jack numbers + # (see long comment above for explanation of RX, TX in + # this context.) + # + # This is used to keep jack IDs in sync between + # get_itf_descriptor() and get_endpoint_descriptors() + return 1 + 2 * (idx + (is_tx * self._num_rx)) + + def get_endpoint_descriptors(self, ep_addr, str_idx): + # One MIDI endpoint in each direction, plus the + # associated CS descriptors + + # The following implementation is *very* memory inefficient + # and needs optimising + + self.ep_out = ep_addr + 1 + self.ep_in = ep_addr + 2 | EP_IN_FLAG + + # rx side, USB "in" endpoint and embedded MIDI IN Jacks + e_out = endpoint_descriptor(self.ep_in, "bulk", 64, 0) + cs_out = struct.pack( + "> 5) & 0x03, + (bmRequestType >> 7) & 0x01, + )