diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index d249c88fc7c..ff48f2c64d1 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -915,7 +915,8 @@ msgstr "" #: ports/mimxrt10xx/common-hal/pwmio/PWMOut.c #: ports/raspberrypi/bindings/picodvi/Framebuffer.c #: ports/raspberrypi/bindings/rp2pio/StateMachine.c -#: ports/raspberrypi/common-hal/picodvi/Framebuffer_RP2040.c py/argcheck.c +#: ports/raspberrypi/common-hal/picodvi/Framebuffer_RP2040.c +#: ports/raspberrypi/common-hal/usb_host_bulk/InStream.c py/argcheck.c #: shared-bindings/digitalio/DigitalInOut.c #: shared-bindings/epaperdisplay/EPaperDisplay.c #: shared-bindings/i2cioexpander/IOPin.c shared-bindings/mipidsi/Display.c @@ -1065,7 +1066,8 @@ msgstr "" msgid "No pull up found on SDA or SCL; check your wiring" msgstr "" -#: ports/atmel-samd/common-hal/busio/UART.c +#: ports/atmel-samd/common-hal/busio/UART.c shared-bindings/usb/core/Device.c +#: shared-bindings/usb_host_bulk/InStream.c msgid "%q must be power of 2" msgstr "" @@ -1146,6 +1148,7 @@ msgstr "" #: ports/atmel-samd/common-hal/picogame/Display.c #: ports/espressif/common-hal/espidf/__init__.c #: ports/raspberrypi/common-hal/picogame/Display.c +#: ports/raspberrypi/common-hal/usb_host_bulk/InStream.c #: shared-bindings/picogame/Canvas.c shared-bindings/picogame/__init__.c msgid "Operation or feature not supported" msgstr "" @@ -1231,6 +1234,7 @@ msgstr "" #: ports/espressif/bindings/espnow/ESPNow.c #: ports/espressif/common-hal/espulp/ULP.c +#: ports/raspberrypi/common-hal/usb_host_bulk/InStream.c #: shared-module/memorymonitor/AllocationAlarm.c #: shared-module/memorymonitor/AllocationSize.c msgid "Already running" @@ -1667,7 +1671,7 @@ msgstr "" #: ports/raspberrypi/common-hal/usb_host/Port.c #: shared-bindings/digitalio/DigitalInOut.c shared-bindings/emmcio/EMMC.c #: shared-bindings/i2cioexpander/IOPin.c shared-bindings/microcontroller/Pin.c -#: shared-module/max3421e/Max3421E.c +#: shared-module/max3421e/Max3421E.c shared-module/usb/core/Device.c msgid "%q in use" msgstr "" @@ -1721,7 +1725,9 @@ msgstr "" msgid "pop from an empty PulseIn" msgstr "" -#: ports/espressif/common-hal/qspibus/QSPIBus.c shared-module/usb/core/Device.c +#: ports/espressif/common-hal/qspibus/QSPIBus.c +#: ports/raspberrypi/common-hal/usb_host_bulk/InStream.c +#: shared-module/usb/core/Device.c msgid "Could not allocate DMA capable buffer" msgstr "" diff --git a/ports/raspberrypi/Makefile b/ports/raspberrypi/Makefile index f961664a081..997cd4681a7 100755 --- a/ports/raspberrypi/Makefile +++ b/ports/raspberrypi/Makefile @@ -614,6 +614,11 @@ SRC_C += \ INC += \ -isystem lib/Pico-PIO-USB/src +ifeq ($(CIRCUITPY_USB_HOST_BULK),1) +# Pico-PIO-USB bulk IN ring, polled from the host frame handler. +CFLAGS += -DPIO_USB_HOST_BULK_STREAM=1 +endif + # Core1 posts USB events while core0 may hold the TinyUSB queue mutex. The # contended wait path calls these two flash-resident SDK functions, and core1 # must not execute from flash. Replace them with RAM-resident implementations diff --git a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk index 76dd0da5f40..f105f7436eb 100644 --- a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk @@ -24,3 +24,6 @@ CFLAGS += -DCFG_TUH_ENUMERATION_BUFSIZE=4096 # is only allocated when CIRCUITPY_USB_HOST_ISO_BUFFER_SIZE is set in # settings.toml. CFLAGS += -DPIO_USB_HOST_ISOCHRONOUS=1 + +# USB host bulk input, for SDR devices +CIRCUITPY_USB_HOST_BULK = 1 diff --git a/ports/raspberrypi/common-hal/usb_host_bulk/InStream.c b/ports/raspberrypi/common-hal/usb_host_bulk/InStream.c new file mode 100644 index 00000000000..6cfcb6ea442 --- /dev/null +++ b/ports/raspberrypi/common-hal/usb_host_bulk/InStream.c @@ -0,0 +1,228 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include + +#include "shared-bindings/usb_host_bulk/InStream.h" + +#include "hardware/sync.h" +#include "py/runtime.h" +#include "shared-bindings/usb/core/__init__.h" +#include "shared-bindings/usb/core/Device.h" +#include "supervisor/port_heap.h" +#include "tusb.h" + +#include "pio_usb_bulk_stream.h" +#include "pio_usb_configuration.h" + +// Store the streams the host is polling, at most one per endpoint, so that if +// a device disappears we can deinit all streams polling it. +static usb_host_bulk_instream_obj_t *_active[PIO_USB_EP_POOL_CNT]; + +static void _forget(usb_host_bulk_instream_obj_t *self) { + for (size_t i = 0; i < MP_ARRAY_SIZE(_active); i++) { + if (_active[i] == self) { + _active[i] = NULL; + } + } +} + +// Detach the ring from the host but keep it, so that bytes captured before +// the stop can still be read. No Python allocation: also used on unplug. +// This waits about a millisecond at most. +static void _stop(usb_host_bulk_instream_obj_t *self) { + while (!pio_usb_host_bulk_stream_stop(self->ring, 1000)) { + } + _forget(self); +} + +static uint32_t _ring_in_waiting(pio_usb_bulk_ring_t *ring) { + uint32_t const read = ring->read_pos; + uint32_t const written = ring->write_pos; + __dmb(); + uint32_t const available = written - read; + return available > ring->capacity ? 0 : available; +} + +static void _free_ring(usb_host_bulk_instream_obj_t *self) { + self->lost_packets = self->ring->stats.overrun_packets; + port_free(self->storage); + port_free(self->ring); + self->ring = NULL; + self->storage = NULL; + self->device = MP_OBJ_NULL; +} + +// Finish deiniting a stream that ended without deinit(), because the +// device stalled the endpoint, was unplugged or was reconfigured: +// move the unread bytes to the VM heap, which the GC cleans up, and +// free the ring. Returns false, leaving the stream as it was, if +// there is no room for them. +static bool _retire(usb_host_bulk_instream_obj_t *self) { + uint32_t remaining = _ring_in_waiting(self->ring); + if (remaining > 0) { + uint8_t *leftover = m_malloc_maybe_without_collect(remaining); + if (leftover == NULL) { + return false; + } + pio_usb_host_bulk_stream_read(self->ring, leftover, remaining); + self->leftover = leftover; + self->leftover_len = remaining; + self->leftover_pos = 0; + } + _free_ring(self); + return true; +} + +void common_hal_usb_host_bulk_instream_construct(usb_host_bulk_instream_obj_t *self, + usb_core_device_obj_t *device, uint8_t endpoint, uint32_t buffer_size) { + size_t slot = MP_ARRAY_SIZE(_active); + for (size_t i = 0; i < MP_ARRAY_SIZE(_active); i++) { + usb_host_bulk_instream_obj_t *other = _active[i]; + if (other != NULL && !other->ring->active) { + // It ended on its own, and finishes deiniting when next used. + _active[i] = other = NULL; + } + if (other == NULL) { + slot = i; + } else if (other->device_address == device->device_address && + other->endpoint == endpoint) { + mp_raise_RuntimeError(MP_ERROR_TEXT("Already running")); + } + } + if (slot == MP_ARRAY_SIZE(_active)) { + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("Too many %q"), MP_QSTR_InStream); + } + tuh_bus_info_t bus_info; + if (!tuh_bus_info_get(device->device_address, &bus_info) || bus_info.rhport < 1 || + bus_info.speed != TUSB_SPEED_FULL) { + mp_raise_usb_core_USBError(MP_ERROR_TEXT("Operation or feature not supported")); + } + if (!common_hal_usb_core_device_open_endpoint(device, endpoint)) { + mp_raise_usb_core_USBError(MP_ERROR_TEXT("Invalid %q"), MP_QSTR_endpoint); + } + // The host core writes both from the frame handler, so they must be in SRAM. + pio_usb_bulk_ring_t *ring = port_malloc(sizeof(*ring), true); + if (ring == NULL) { + mp_raise_msg(&mp_type_MemoryError, MP_ERROR_TEXT("Could not allocate DMA capable buffer")); + } + memset(ring, 0, sizeof(*ring)); + uint8_t *storage = port_malloc(buffer_size, true); + if (storage == NULL) { + port_free(ring); + mp_raise_msg(&mp_type_MemoryError, MP_ERROR_TEXT("Could not allocate DMA capable buffer")); + } + // TinyUSB numbers the PIO root ports from 1. + if (!pio_usb_host_bulk_stream_start(bus_info.rhport - 1, device->device_address, endpoint, + ring, storage, buffer_size)) { + port_free(storage); + port_free(ring); + mp_raise_usb_core_USBError(MP_ERROR_TEXT("Invalid %q"), MP_QSTR_endpoint); + } + self->device = MP_OBJ_FROM_PTR(device); + self->ring = ring; + self->storage = storage; + self->leftover = NULL; + self->leftover_len = 0; + self->leftover_pos = 0; + self->lost_packets = 0; + self->device_address = device->device_address; + self->endpoint = endpoint; + _active[slot] = self; +} + +bool common_hal_usb_host_bulk_instream_deinited(usb_host_bulk_instream_obj_t *self) { + if (self->device != MP_OBJ_NULL && !self->ring->active) { + // Clearing active is the host's last access, so the ring now holds + // every byte it wrote. + __dmb(); + _retire(self); + } + return self->device == MP_OBJ_NULL; +} + +// Also the finaliser +void common_hal_usb_host_bulk_instream_deinit(usb_host_bulk_instream_obj_t *self) { + if (self->device != MP_OBJ_NULL) { + _stop(self); + _free_ring(self); + } + // Drop the bytes left by an ended stream. The GC frees their buffer. + self->leftover = NULL; + self->leftover_len = 0; + self->leftover_pos = 0; +} + +uint32_t common_hal_usb_host_bulk_instream_read(usb_host_bulk_instream_obj_t *self, uint8_t *data, uint32_t len) { + if (self->ring != NULL) { + return pio_usb_host_bulk_stream_read(self->ring, data, len); + } + uint32_t count = MIN(len, self->leftover_len - self->leftover_pos); + if (count > 0) { + memcpy(data, self->leftover + self->leftover_pos, count); + self->leftover_pos += count; + } + if (self->leftover != NULL && self->leftover_pos == self->leftover_len) { + m_del(uint8_t, self->leftover, self->leftover_len); + self->leftover = NULL; + self->leftover_len = 0; + self->leftover_pos = 0; + } + return count; +} + +uint32_t common_hal_usb_host_bulk_instream_get_in_waiting(usb_host_bulk_instream_obj_t *self) { + if (self->ring != NULL) { + return _ring_in_waiting(self->ring); + } + return self->leftover_len - self->leftover_pos; +} + +uint32_t common_hal_usb_host_bulk_instream_get_lost_packets(usb_host_bulk_instream_obj_t *self) { + if (self->ring != NULL) { + return self->ring->stats.overrun_packets; + } + return self->lost_packets; +} + +void common_hal_usb_host_bulk_instream_reset_input_buffer(usb_host_bulk_instream_obj_t *self) { + // read_pos belongs to the consumer, so moving it is safe while capturing. + self->ring->read_pos = self->ring->write_pos; +} + +// Stops every stream from device_address, or every stream opened through +// device if it is not NULL. +static void _stop_matching(uint8_t device_address, usb_core_device_obj_t *device) { + for (size_t i = 0; i < MP_ARRAY_SIZE(_active); i++) { + usb_host_bulk_instream_obj_t *stream = _active[i]; + if (stream == NULL) { + continue; + } + if (device != NULL ? stream->device == MP_OBJ_FROM_PTR(device) : + stream->device_address == device_address) { + _stop(stream); + } + } +} + +void usb_host_bulk_stop_device(uint8_t device_address) { + _stop_matching(device_address, NULL); +} + +bool usb_host_bulk_endpoint_busy(uint8_t device_address, uint8_t endpoint) { + for (size_t i = 0; i < MP_ARRAY_SIZE(_active); i++) { + usb_host_bulk_instream_obj_t *stream = _active[i]; + if (stream != NULL && stream->device_address == device_address && + stream->endpoint == endpoint && stream->ring->active) { + return true; + } + } + return false; +} + +void usb_host_bulk_device_deinit(usb_core_device_obj_t *device) { + _stop_matching(0, device); +} diff --git a/ports/raspberrypi/common-hal/usb_host_bulk/InStream.h b/ports/raspberrypi/common-hal/usb_host_bulk/InStream.h new file mode 100644 index 00000000000..632298842bf --- /dev/null +++ b/ports/raspberrypi/common-hal/usb_host_bulk/InStream.h @@ -0,0 +1,26 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +#include "py/obj.h" + +#include "pio_usb_bulk_stream.h" + +typedef struct { + mp_obj_base_t base; + mp_obj_t device; // keeps the usb.core.Device alive; NULL once deinited + pio_usb_bulk_ring_t *ring; // port heap; NULL once freed + uint8_t *storage; + uint8_t *leftover; // VM heap: bytes unread when the host ended the stream + uint32_t leftover_len; + uint32_t leftover_pos; + uint32_t lost_packets; // latched when the ring is freed + uint8_t device_address; // copies, so deinit never touches `device` + uint8_t endpoint; +} usb_host_bulk_instream_obj_t; diff --git a/ports/raspberrypi/common-hal/usb_host_bulk/__init__.c b/ports/raspberrypi/common-hal/usb_host_bulk/__init__.c new file mode 100644 index 00000000000..81be9d0b79d --- /dev/null +++ b/ports/raspberrypi/common-hal/usb_host_bulk/__init__.c @@ -0,0 +1,7 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +// Nothing diff --git a/py/circuitpy_defns.mk b/py/circuitpy_defns.mk index e537c821122..85322ce61ee 100755 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -471,6 +471,9 @@ endif ifeq ($(CIRCUITPY_USB_HOST),1) SRC_PATTERNS += usb_host/% endif +ifeq ($(CIRCUITPY_USB_HOST_BULK),1) +SRC_PATTERNS += usb_host_bulk/% +endif ifeq ($(CIRCUITPY_USB_MIDI),1) SRC_PATTERNS += usb_midi/% endif @@ -610,6 +613,8 @@ SRC_COMMON_HAL_ALL = \ spitarget/__init__.c \ usb_host/__init__.c \ usb_host/Port.c \ + usb_host_bulk/__init__.c \ + usb_host_bulk/InStream.c \ watchdog/WatchDogMode.c \ watchdog/WatchDogTimer.c \ watchdog/__init__.c \ diff --git a/py/circuitpy_mpconfig.h b/py/circuitpy_mpconfig.h index 85e0b9fb66d..0afbf462618 100644 --- a/py/circuitpy_mpconfig.h +++ b/py/circuitpy_mpconfig.h @@ -602,6 +602,11 @@ void background_callback_run_all(void); #define CIRCUITPY_USB_HOST_HIGH_SPEED 0 #endif +// usb_host_bulk takes a usb.core.Device and uses TinyUSB host calls. +#if CIRCUITPY_USB_HOST_BULK && !(CIRCUITPY_USB_HOST && CIRCUITPY_PYUSB) +#error CIRCUITPY_USB_HOST_BULK requires CIRCUITPY_USB_HOST and CIRCUITPY_PYUSB +#endif + // If the port requires certain USB endpoint numbers, define these in mpconfigport.h. #ifndef USB_CDC_EP_NUM_NOTIFICATION diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 484e5d60d21..a369b3e6c4f 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -786,6 +786,10 @@ CFLAGS += -DCIRCUITPY_USB_HOST=$(CIRCUITPY_USB_HOST) CIRCUITPY_PYUSB ?= $(call enable-if-any,$(CIRCUITPY_USB_HOST) $(CIRCUITPY_MAX3421E)) CFLAGS += -DCIRCUITPY_PYUSB=$(CIRCUITPY_PYUSB) +# Continuous bulk transfers on PIO USB host ports. Opt-in per board. +CIRCUITPY_USB_HOST_BULK ?= 0 +CFLAGS += -DCIRCUITPY_USB_HOST_BULK=$(CIRCUITPY_USB_HOST_BULK) + CIRCUITPY_USB_KEYBOARD_WORKFLOW ?= $(call enable-if-any,$(CIRCUITPY_USB_HOST) $(CIRCUITPY_MAX3421E)) CFLAGS += -DCIRCUITPY_USB_KEYBOARD_WORKFLOW=$(CIRCUITPY_USB_KEYBOARD_WORKFLOW) diff --git a/shared-bindings/usb/core/Device.h b/shared-bindings/usb/core/Device.h index 51338f61228..5814469240a 100644 --- a/shared-bindings/usb/core/Device.h +++ b/shared-bindings/usb/core/Device.h @@ -25,6 +25,7 @@ mp_obj_t common_hal_usb_core_device_get_port_numbers(usb_core_device_obj_t *self mp_int_t common_hal_usb_core_device_get_speed(usb_core_device_obj_t *self); void common_hal_usb_core_device_set_configuration(usb_core_device_obj_t *self, mp_int_t configuration); +bool common_hal_usb_core_device_open_endpoint(usb_core_device_obj_t *self, mp_int_t endpoint); mp_int_t common_hal_usb_core_device_write(usb_core_device_obj_t *self, mp_int_t endpoint, const uint8_t *buffer, mp_int_t len, mp_int_t timeout); mp_int_t common_hal_usb_core_device_read(usb_core_device_obj_t *self, mp_int_t endpoint, uint8_t *buffer, mp_int_t len, mp_int_t timeout, bool raise_on_timeout); mp_int_t common_hal_usb_core_device_ctrl_transfer(usb_core_device_obj_t *self, diff --git a/shared-bindings/usb_host_bulk/InStream.c b/shared-bindings/usb_host_bulk/InStream.c new file mode 100644 index 00000000000..803581805c3 --- /dev/null +++ b/shared-bindings/usb_host_bulk/InStream.c @@ -0,0 +1,303 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "py/objproperty.h" +#include "py/runtime.h" +#include "py/stream.h" + +#include "shared/runtime/context_manager_helpers.h" +#include "shared-bindings/usb/core/Device.h" +#include "shared-bindings/usb_host_bulk/InStream.h" +#include "shared-bindings/util.h" + +//| import usb.core +//| +//| +//| class InStream: +//| """Continuously receive from a full-speed bulk IN endpoint in the background. +//| +//| The USB host polls the endpoint every frame, independently of Python, and stores +//| the payload in a ring of ``buffer_size`` bytes in internal SRAM. +//| +//| Reads never block. To wait for data, wrap the stream with ``asyncio.StreamReader`` +//| or poll `in_waiting`. +//| +//| One stream per endpoint can run. They share the bandwidth of +//| their host port. +//| +//| Synchronous example:: +//| +//| import time +//| import usb.core +//| import usb_host_bulk +//| +//| # Replace these with your device's vendor and product IDs. +//| device = usb.core.find(idVendor=0x0BDA, idProduct=0x2838) +//| if device is None: +//| raise RuntimeError("USB device not found") +//| device.set_configuration() +//| # Many devices only start sending after device-specific setup, such as +//| # vendor requests made with device.ctrl_transfer(). Do that here. +//| +//| buf = bytearray(8192) +//| received = 0 +//| report_at = time.monotonic() + 1 +//| with usb_host_bulk.InStream(device, 0x81) as stream: +//| while True: +//| n = stream.readinto(buf) # None while nothing is waiting +//| if n == 0: +//| break # the device stalled or was unplugged +//| if n: +//| received += n # process buf[:n] here +//| if time.monotonic() >= report_at: +//| print("received", received, "bytes, lost packets:", stream.lost_packets) +//| report_at += 1 +//| +//| asyncio example:: +//| +//| import asyncio +//| from asyncio import StreamReader +//| import usb.core +//| import usb_host_bulk +//| +//| # Replace these with your device's vendor and product IDs. +//| device = usb.core.find(idVendor=0x0BDA, idProduct=0x2838) +//| if device is None: +//| raise RuntimeError("USB device not found") +//| device.set_configuration() +//| # Many devices only start sending after device-specific setup, such as +//| # vendor requests made with device.ctrl_transfer(). Do that here. +//| +//| received = 0 +//| +//| async def receive(stream): +//| global received +//| reader = StreamReader(stream) +//| buf = bytearray(8192) +//| while True: +//| n = await reader.readinto(buf) +//| if not n: +//| break # the device stalled or was unplugged +//| received += n # process buf[:n] here +//| +//| async def report(stream): +//| while True: +//| await asyncio.sleep(1) +//| print("received", received, "bytes, lost packets:", stream.lost_packets) +//| +//| async def main(): +//| with usb_host_bulk.InStream(device, 0x81) as stream: +//| reporter = asyncio.create_task(report(stream)) +//| await receive(stream) +//| reporter.cancel() +//| +//| asyncio.run(main()) +//| +//| ``asyncio.StreamReader(stream)`` is a CircuitPython and MicroPython idiom. Import +//| ``StreamReader`` before starting the stream: asyncio loads it on first use, which +//| can take long enough to overflow the ring. Use ``readinto`` rather than +//| ``readexactly`` at high data rates, because ``readexactly`` allocates on every +//| call. +//| """ +//| +//| def __init__( +//| self, device: usb.core.Device, endpoint: int, *, buffer_size: int = 32768 +//| ) -> None: +//| """Open ``endpoint`` on ``device`` and start capturing immediately. +//| +//| :param usb.core.Device device: a configured device (call ``set_configuration()`` first) +//| :param int endpoint: bEndpointAddress of a bulk IN endpoint, 0x81 to 0x8F, with +//| a maximum packet size of 64 bytes or less +//| :param int buffer_size: ring size in bytes, a power of two from 4096 to 65536 +//| """ +//| ... +//| +static mp_obj_t usb_host_bulk_instream_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_device, ARG_endpoint, ARG_buffer_size }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_device, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_endpoint, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_buffer_size, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 32768} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + mp_obj_t device_obj = mp_arg_validate_type(args[ARG_device].u_obj, &usb_core_device_type, MP_QSTR_device); + usb_core_device_obj_t *device = MP_OBJ_TO_PTR(device_obj); + if (common_hal_usb_core_device_deinited(device)) { + raise_deinited_error(); + } + // IN endpoint addresses 1-15; endpoint 0 is control only. + mp_int_t endpoint = mp_arg_validate_int_range(args[ARG_endpoint].u_int, 0x81, 0x8F, MP_QSTR_endpoint); + mp_int_t buffer_size = mp_arg_validate_int_range(args[ARG_buffer_size].u_int, 4096, 65536, MP_QSTR_buffer_size); + if ((buffer_size & (buffer_size - 1)) != 0) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be power of 2"), MP_QSTR_buffer_size); + } + + usb_host_bulk_instream_obj_t *self = mp_obj_malloc_with_finaliser(usb_host_bulk_instream_obj_t, &usb_host_bulk_instream_type); + common_hal_usb_host_bulk_instream_construct(self, device, endpoint, buffer_size); + return MP_OBJ_FROM_PTR(self); +} + +static void check_for_deinit(usb_host_bulk_instream_obj_t *self) { + if (common_hal_usb_host_bulk_instream_deinited(self)) { + raise_deinited_error(); + } +} + +//| def readinto(self, buf: WriteableBuffer, nbytes: Optional[int] = None) -> Optional[int]: +//| """Copy waiting bytes into ``buf``, at most ``nbytes`` of them if given. +//| +//| Capture ends when the stream is deinited, which drops any bytes not yet read. +//| It also ends on its own when the device stalls the endpoint, is unplugged, or is +//| reconfigured. Bytes captured before that can still be read. +//| +//| :return: the number of bytes copied, ``None`` if nothing is waiting yet, or 0 +//| once capture has ended and nothing is left to read +//| :rtype: int or None""" +//| ... +//| +//| def read(self, nbytes: Optional[int] = None) -> Optional[bytes]: +//| """Same as `readinto`, but allocates and returns ``bytes``: ``None`` if nothing +//| is waiting yet, ``b""`` once capture has ended and nothing is left to read.""" +//| ... +//| + +// Standard stream methods, implemented in py/stream.c on top of this. +static mp_uint_t usb_host_bulk_instream_read_stream(mp_obj_t self_in, void *buf, mp_uint_t size, int *errcode) { + usb_host_bulk_instream_obj_t *self = MP_OBJ_TO_PTR(self_in); + // Look at deinited first: a stream seen deinited here has all of its data + // visible below, so an empty read means the end. + bool deinited = common_hal_usb_host_bulk_instream_deinited(self); + uint32_t count = common_hal_usb_host_bulk_instream_read(self, buf, size); + if (count > 0 || deinited) { + return count; + } + *errcode = MP_EAGAIN; + return MP_STREAM_ERROR; +} + +static mp_uint_t usb_host_bulk_instream_ioctl(mp_obj_t self_in, mp_uint_t request, uintptr_t arg, int *errcode) { + usb_host_bulk_instream_obj_t *self = MP_OBJ_TO_PTR(self_in); + if (request == MP_STREAM_CLOSE) { + common_hal_usb_host_bulk_instream_deinit(self); + return 0; + } + if (request == MP_STREAM_POLL) { + mp_uint_t flags = arg; + mp_uint_t ret = 0; + // A deinited stream is readable (it returns EOF) so that waiters wake up. + bool deinited = common_hal_usb_host_bulk_instream_deinited(self); + if ((flags & MP_STREAM_POLL_RD) && + (deinited || common_hal_usb_host_bulk_instream_get_in_waiting(self) > 0)) { + ret |= MP_STREAM_POLL_RD; + } + if (deinited) { + ret |= MP_STREAM_POLL_HUP; + } + return ret; + } + *errcode = MP_EINVAL; + return MP_STREAM_ERROR; +} + +//| in_waiting: int +//| """Bytes waiting to be read, including those left when capture ended on its own. +//| (read-only)""" +//| +static mp_obj_t usb_host_bulk_instream_obj_get_in_waiting(mp_obj_t self_in) { + usb_host_bulk_instream_obj_t *self = MP_OBJ_TO_PTR(self_in); + return mp_obj_new_int_from_uint(common_hal_usb_host_bulk_instream_get_in_waiting(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(usb_host_bulk_instream_get_in_waiting_obj, usb_host_bulk_instream_obj_get_in_waiting); + +MP_PROPERTY_GETTER(usb_host_bulk_instream_in_waiting_obj, + (mp_obj_t)&usb_host_bulk_instream_get_in_waiting_obj); + +//| lost_packets: int +//| """Packets dropped because the ring was full, meaning reads were not frequent +//| enough. Zero means the captured stream has no gaps. Readable after `deinit`. +//| (read-only)""" +//| +static mp_obj_t usb_host_bulk_instream_obj_get_lost_packets(mp_obj_t self_in) { + usb_host_bulk_instream_obj_t *self = MP_OBJ_TO_PTR(self_in); + return mp_obj_new_int_from_uint(common_hal_usb_host_bulk_instream_get_lost_packets(self)); +} +MP_DEFINE_CONST_FUN_OBJ_1(usb_host_bulk_instream_get_lost_packets_obj, usb_host_bulk_instream_obj_get_lost_packets); + +MP_PROPERTY_GETTER(usb_host_bulk_instream_lost_packets_obj, + (mp_obj_t)&usb_host_bulk_instream_get_lost_packets_obj); + +//| def reset_input_buffer(self) -> None: +//| """Discard every byte waiting in the ring. Raises `ValueError` once the stream +//| is deinited, because no more data will come.""" +//| ... +//| +static mp_obj_t usb_host_bulk_instream_obj_reset_input_buffer(mp_obj_t self_in) { + usb_host_bulk_instream_obj_t *self = MP_OBJ_TO_PTR(self_in); + check_for_deinit(self); + common_hal_usb_host_bulk_instream_reset_input_buffer(self); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(usb_host_bulk_instream_reset_input_buffer_obj, usb_host_bulk_instream_obj_reset_input_buffer); + +//| def deinit(self) -> None: +//| """Stop capture and free the ring, dropping any bytes not yet read.""" +//| ... +//| +static mp_obj_t usb_host_bulk_instream_obj_deinit(mp_obj_t self_in) { + usb_host_bulk_instream_obj_t *self = MP_OBJ_TO_PTR(self_in); + common_hal_usb_host_bulk_instream_deinit(self); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(usb_host_bulk_instream_deinit_obj, usb_host_bulk_instream_obj_deinit); + +//| def __enter__(self) -> InStream: +//| """No-op used by Context Managers.""" +//| ... +//| +// Provided by context manager helper. + +//| def __exit__(self) -> None: +//| """Automatically deinitializes when exiting a context. See +//| :ref:`lifetime-and-contextmanagers` for more info.""" +//| ... +//| +//| +// Provided by context manager helper. + +static const mp_rom_map_elem_t usb_host_bulk_instream_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR___del__), MP_ROM_PTR(&usb_host_bulk_instream_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR_deinit), MP_ROM_PTR(&usb_host_bulk_instream_deinit_obj) }, + { MP_ROM_QSTR(MP_QSTR___enter__), MP_ROM_PTR(&default___enter___obj) }, + { MP_ROM_QSTR(MP_QSTR___exit__), MP_ROM_PTR(&default___exit___obj) }, + + // Standard stream methods. + { MP_ROM_QSTR(MP_QSTR_read), MP_ROM_PTR(&mp_stream_read_obj) }, + { MP_ROM_QSTR(MP_QSTR_readinto), MP_ROM_PTR(&mp_stream_readinto_obj) }, + + { MP_ROM_QSTR(MP_QSTR_reset_input_buffer), MP_ROM_PTR(&usb_host_bulk_instream_reset_input_buffer_obj) }, + + // Properties + { MP_ROM_QSTR(MP_QSTR_in_waiting), MP_ROM_PTR(&usb_host_bulk_instream_in_waiting_obj) }, + { MP_ROM_QSTR(MP_QSTR_lost_packets), MP_ROM_PTR(&usb_host_bulk_instream_lost_packets_obj) }, +}; +static MP_DEFINE_CONST_DICT(usb_host_bulk_instream_locals_dict, usb_host_bulk_instream_locals_dict_table); + +static const mp_stream_p_t usb_host_bulk_instream_stream_p = { + .read = usb_host_bulk_instream_read_stream, + .ioctl = usb_host_bulk_instream_ioctl, + .is_text = false, +}; + +MP_DEFINE_CONST_OBJ_TYPE( + usb_host_bulk_instream_type, + MP_QSTR_InStream, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, usb_host_bulk_instream_make_new, + locals_dict, &usb_host_bulk_instream_locals_dict, + protocol, &usb_host_bulk_instream_stream_p + ); diff --git a/shared-bindings/usb_host_bulk/InStream.h b/shared-bindings/usb_host_bulk/InStream.h new file mode 100644 index 00000000000..f62cbc413fc --- /dev/null +++ b/shared-bindings/usb_host_bulk/InStream.h @@ -0,0 +1,42 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +#include "common-hal/usb_host_bulk/InStream.h" +#include "shared-module/usb/core/Device.h" + +extern const mp_obj_type_t usb_host_bulk_instream_type; + +void common_hal_usb_host_bulk_instream_construct(usb_host_bulk_instream_obj_t *self, + usb_core_device_obj_t *device, uint8_t endpoint, uint32_t buffer_size); +// Stops capture, waits for the host to let go of the ring, frees it and drops +// the unread bytes. It is also the finaliser. +void common_hal_usb_host_bulk_instream_deinit(usb_host_bulk_instream_obj_t *self); +// A stream that ended without deinit(), because the device stalled the +// endpoint, was unplugged or was reconfigured, is deinited here, keeping its +// unread bytes readable on the VM heap, so this may allocate: call it only +// from the VM. +bool common_hal_usb_host_bulk_instream_deinited(usb_host_bulk_instream_obj_t *self); +uint32_t common_hal_usb_host_bulk_instream_read(usb_host_bulk_instream_obj_t *self, uint8_t *data, uint32_t len); +uint32_t common_hal_usb_host_bulk_instream_get_in_waiting(usb_host_bulk_instream_obj_t *self); +uint32_t common_hal_usb_host_bulk_instream_get_lost_packets(usb_host_bulk_instream_obj_t *self); +void common_hal_usb_host_bulk_instream_reset_input_buffer(usb_host_bulk_instream_obj_t *self); + +// Hooks for usb.core.Device, which must not leave a stream polling an endpoint +// it reconfigures, reads, writes or closes. + +// Stops every stream from device_address. Their unread bytes stay readable. +// Never raises or allocates, so it is safe from TinyUSB callbacks. +void usb_host_bulk_stop_device(uint8_t device_address); +// True while a stream is polling this endpoint. +bool usb_host_bulk_endpoint_busy(uint8_t device_address, uint8_t endpoint); +// Stops the streams opened through this Device object, but not ones opened +// through another Device for the same address. Never raises or allocates. +void usb_host_bulk_device_deinit(usb_core_device_obj_t *device); diff --git a/shared-bindings/usb_host_bulk/__init__.c b/shared-bindings/usb_host_bulk/__init__.c new file mode 100644 index 00000000000..7cb3e4f58ac --- /dev/null +++ b/shared-bindings/usb_host_bulk/__init__.c @@ -0,0 +1,36 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#include "py/obj.h" +#include "py/runtime.h" + +#include "shared-bindings/usb_host_bulk/__init__.h" +#include "shared-bindings/usb_host_bulk/InStream.h" + +//| """Continuous bulk transfers on USB host ports +//| +//| This module keeps an endpoint busy in the background so that +//| continuously-streaming devices, such as RTL2832U SDR dongles, never overflow. +//| Devices are still found and configured with `usb.core`; `usb_host` manages the +//| host ports themselves. +//| +//| Available only on boards built with the PIO USB host and this feature enabled. +//| """ +//| + +static const mp_rom_map_elem_t usb_host_bulk_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_usb_host_bulk) }, + { MP_ROM_QSTR(MP_QSTR_InStream), MP_ROM_PTR(&usb_host_bulk_instream_type) }, +}; + +static MP_DEFINE_CONST_DICT(usb_host_bulk_module_globals, usb_host_bulk_module_globals_table); + +const mp_obj_module_t usb_host_bulk_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&usb_host_bulk_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_usb_host_bulk, usb_host_bulk_module); diff --git a/shared-bindings/usb_host_bulk/__init__.h b/shared-bindings/usb_host_bulk/__init__.h new file mode 100644 index 00000000000..779b49ffd8d --- /dev/null +++ b/shared-bindings/usb_host_bulk/__init__.h @@ -0,0 +1,7 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Tim Cocks for Adafruit Industries +// +// SPDX-License-Identifier: MIT + +#pragma once diff --git a/shared-module/usb/core/Device.c b/shared-module/usb/core/Device.c index a3bb449e87e..9c177c057a9 100644 --- a/shared-module/usb/core/Device.c +++ b/shared-module/usb/core/Device.c @@ -21,6 +21,24 @@ #include "supervisor/shared/tick.h" #include "supervisor/usb.h" +#if CIRCUITPY_USB_HOST_BULK +#include "shared-bindings/usb_host_bulk/InStream.h" +#else +static inline void usb_host_bulk_stop_device(uint8_t device_address) { + (void)device_address; +} + +static inline bool usb_host_bulk_endpoint_busy(uint8_t device_address, uint8_t endpoint) { + (void)device_address; + (void)endpoint; + return false; +} + +static inline void usb_host_bulk_device_deinit(usb_core_device_obj_t *device) { + (void)device; +} +#endif + // Track what device numbers are mounted. We can't use tuh_ready() because it is // true before enumeration completes and TinyUSB drivers are started. static size_t _mounted_devices = 0; @@ -31,6 +49,7 @@ void tuh_mount_cb(uint8_t dev_addr) { void tuh_umount_cb(uint8_t dev_addr) { _mounted_devices &= ~(1 << dev_addr); + usb_host_bulk_stop_device(dev_addr); } static xfer_result_t _xfer_result; @@ -82,6 +101,7 @@ void common_hal_usb_core_device_deinit(usb_core_device_obj_t *self) { if (common_hal_usb_core_device_deinited(self)) { return; } + usb_host_bulk_device_deinit(self); size_t open_size = sizeof(self->open_endpoints); for (size_t i = 0; i < open_size; i++) { if (self->open_endpoints[i] != 0) { @@ -377,6 +397,7 @@ mp_int_t common_hal_usb_core_device_get_speed(usb_core_device_obj_t *self) { void common_hal_usb_core_device_set_configuration(usb_core_device_obj_t *self, mp_int_t configuration) { // We assume that the config index is one less than the value. uint8_t config_index = configuration - 1; + usb_host_bulk_stop_device(self->device_address); // Get the configuration descriptor and cache it. We'll use it later to open // endpoints. @@ -460,7 +481,18 @@ static bool _open_endpoint(usb_core_device_obj_t *self, mp_int_t endpoint) { return open; } +bool common_hal_usb_core_device_open_endpoint(usb_core_device_obj_t *self, mp_int_t endpoint) { + return _open_endpoint(self, endpoint); +} + +static void _check_endpoint_free(usb_core_device_obj_t *self, mp_int_t endpoint) { + if (usb_host_bulk_endpoint_busy(self->device_address, endpoint)) { + mp_raise_usb_core_USBError(MP_ERROR_TEXT("%q in use"), MP_QSTR_endpoint); + } +} + mp_int_t common_hal_usb_core_device_write(usb_core_device_obj_t *self, mp_int_t endpoint, const uint8_t *buffer, mp_int_t len, mp_int_t timeout) { + _check_endpoint_free(self, endpoint); if (!_open_endpoint(self, endpoint)) { mp_raise_usb_core_USBError(NULL); return 0; @@ -492,6 +524,7 @@ mp_int_t common_hal_usb_core_device_write(usb_core_device_obj_t *self, mp_int_t } mp_int_t common_hal_usb_core_device_read(usb_core_device_obj_t *self, mp_int_t endpoint, uint8_t *buffer, mp_int_t len, mp_int_t timeout, bool raise_on_timeout) { + _check_endpoint_free(self, endpoint); if (!_open_endpoint(self, endpoint)) { mp_raise_usb_core_USBError(NULL); return 0; @@ -530,6 +563,11 @@ mp_int_t common_hal_usb_core_device_ctrl_transfer(usb_core_device_obj_t *self, mp_int_t bmRequestType, mp_int_t bRequest, mp_int_t wValue, mp_int_t wIndex, uint8_t *buffer, mp_int_t len, mp_int_t timeout) { + // Stop a stream before the device changes or disables its endpoint. + if (len == 0 && ((bmRequestType == 0x01 && bRequest == TUSB_REQ_SET_INTERFACE) || + (bmRequestType == 0x00 && bRequest == TUSB_REQ_SET_CONFIGURATION))) { + usb_host_bulk_stop_device(self->device_address); + } // Timeout is in ms. #if !CIRCUITPY_ALL_MEMORY_DMA_CAPABLE