diff --git a/ports/rp2/CMakeLists.txt b/ports/rp2/CMakeLists.txt index ff6f5b536d7..31ebe3338aa 100644 --- a/ports/rp2/CMakeLists.txt +++ b/ports/rp2/CMakeLists.txt @@ -244,6 +244,8 @@ if(MICROPY_HW_USB_HOST) usb_gamepad_mod.c usb_cdc.c usb_cdc_mod.c + usb_msc.c + machine_usbdrive.c ) endif() @@ -298,6 +300,7 @@ if(MICROPY_HW_USB_HOST) ${MICROPY_PORT_DIR}/usb_mouse_mod.c ${MICROPY_PORT_DIR}/usb_gamepad_mod.c ${MICROPY_PORT_DIR}/usb_cdc_mod.c + ${MICROPY_PORT_DIR}/machine_usbdrive.c ) endif() if(MICROPY_HW_ENABLE_XMODEM) @@ -479,6 +482,66 @@ if (MICROPY_BLUETOOTH_BTSTACK) list(APPEND MICROPY_INC_CORE ${BTSTACK_INCLUDE}) endif() +# Bluetooth Classic A2DP source (stream audio out to a Bluetooth speaker), +# the `btaudio` module. Adds just the classic-profile BTstack sources this +# needs (a2dp/avdtp source role + SDP + the SBC encoder) directly to the +# firmware target, rather than linking pico-sdk's pico_btstack_classic +# library -- that would pull in its own copy of hci.c/l2cap.c, duplicating +# the ones micropy_extmod_btstack above already compiles for BLE. Classic and +# LE share ONE BTstack/HCI instance (that's the whole point of dual-mode) -- +# hci.c/l2cap.c just need ENABLE_CLASSIC=1 applied to their one compilation, +# which target_compile_definitions on the firmware target itself achieves +# (it applies to every source pulled into this target, including +# micropy_extmod_btstack's INTERFACE sources). +if (MICROPY_HW_ENABLE_BT_A2DP) + if (NOT MICROPY_BLUETOOTH_BTSTACK) + message(FATAL_ERROR "MICROPY_HW_ENABLE_BT_A2DP requires MICROPY_BLUETOOTH_BTSTACK") + endif() + + list(APPEND MICROPY_SOURCE_PORT + btaudio.c + ${BTSTACK_LIB_DIR}/src/classic/a2dp.c + ${BTSTACK_LIB_DIR}/src/classic/a2dp_source.c + ${BTSTACK_LIB_DIR}/src/classic/avdtp.c + ${BTSTACK_LIB_DIR}/src/classic/avdtp_acceptor.c + ${BTSTACK_LIB_DIR}/src/classic/avdtp_initiator.c + ${BTSTACK_LIB_DIR}/src/classic/avdtp_source.c + ${BTSTACK_LIB_DIR}/src/classic/avdtp_util.c + ${BTSTACK_LIB_DIR}/src/classic/sdp_client.c + ${BTSTACK_LIB_DIR}/src/classic/sdp_server.c + ${BTSTACK_LIB_DIR}/src/classic/sdp_util.c + ${BTSTACK_LIB_DIR}/src/classic/btstack_sbc_encoder_bluedroid.c + ${BTSTACK_LIB_DIR}/3rd-party/bluedroid/encoder/srce/sbc_analysis.c + ${BTSTACK_LIB_DIR}/3rd-party/bluedroid/encoder/srce/sbc_dct.c + ${BTSTACK_LIB_DIR}/3rd-party/bluedroid/encoder/srce/sbc_dct_coeffs.c + ${BTSTACK_LIB_DIR}/3rd-party/bluedroid/encoder/srce/sbc_enc_bit_alloc_mono.c + ${BTSTACK_LIB_DIR}/3rd-party/bluedroid/encoder/srce/sbc_enc_bit_alloc_ste.c + ${BTSTACK_LIB_DIR}/3rd-party/bluedroid/encoder/srce/sbc_enc_coeffs.c + ${BTSTACK_LIB_DIR}/3rd-party/bluedroid/encoder/srce/sbc_encoder.c + ${BTSTACK_LIB_DIR}/3rd-party/bluedroid/encoder/srce/sbc_packing.c + ) + list(APPEND MICROPY_SOURCE_QSTR ${MICROPY_PORT_DIR}/btaudio.c) + + target_compile_definitions(${MICROPY_TARGET} PRIVATE + MICROPY_HW_ENABLE_BT_A2DP=1 + ENABLE_CLASSIC=1 + ) + # Quiets known-noisy but harmless warnings in this vendored code (same + # suppressions pico-sdk's own pico_btstack_classic target applies). + set_source_files_properties( + ${BTSTACK_LIB_DIR}/src/classic/a2dp.c + ${BTSTACK_LIB_DIR}/src/classic/a2dp_source.c + ${BTSTACK_LIB_DIR}/src/classic/avdtp.c + ${BTSTACK_LIB_DIR}/src/classic/avdtp_source.c + ${BTSTACK_LIB_DIR}/src/classic/sdp_server.c + PROPERTIES COMPILE_OPTIONS "-Wno-cast-qual" + ) + set_source_files_properties( + ${BTSTACK_LIB_DIR}/src/classic/avdtp_util.c + PROPERTIES COMPILE_OPTIONS "-Wno-unused-parameter" + ) +endif() + if(MICROPY_BLUETOOTH_NIMBLE) list(APPEND GIT_SUBMODULES lib/mynewt-nimble) if(NOT UPDATE_SUBMODULES AND NOT EXISTS ${MICROPY_DIR}/lib/mynewt-nimble/nimble/host/include/host/ble_hs.h) diff --git a/ports/rp2/boards/PICO_COMPUTER_3/USER_MANUAL.md b/ports/rp2/boards/PICO_COMPUTER_3/USER_MANUAL.md index 3aa39ed4f5f..c2782302c1a 100644 --- a/ports/rp2/boards/PICO_COMPUTER_3/USER_MANUAL.md +++ b/ports/rp2/boards/PICO_COMPUTER_3/USER_MANUAL.md @@ -52,7 +52,7 @@ over the artwork otherwise) and restore `console()` in a `finally:`. XMODEM transfers use the serial port directly and work in any mode. The USB device port is disabled (there is no USB-CDC prompt); the USB port is a -**host** port for keyboards, mice and touch panels. +**host** port for keyboards, mice, touch panels and USB flash drives. At the prompt you can type Python directly, or use the shell-style helpers (section 4). To run a program from the SD card or flash: @@ -94,7 +94,7 @@ cannot be claimed with `machine.Pin()`. | PSRAM chip-select | **GP47** | 8 MB PSRAM | | Wi-Fi / Bluetooth (CYW43) | REG_ON **GP23**, DATA **GP24**, CS **GP25**, CLK **GP29** | wireless chip over PIO-SPI — *reserved* | | Status LED | CYW43 **GPIO0** | on the wireless chip (not an RP2350 pin) — named pin `"LED"` | -| USB host | native USB port | keyboard / mouse / touch | +| USB host | native USB port | keyboard / mouse / touch / flash drive | The **status LED** is on the CYW43 wireless chip, not on an RP2350 GPIO. It is exposed as the named pin `"LED"` (an "extended" pin) and driven like any output — @@ -166,6 +166,7 @@ MicroPython v1.29.0 on PICO COMPUTER 2 v0.16 with RP2350B - **Floats:** double precision (64-bit). Integers are arbitrary precision. - **Flash filesystem:** 12 MB LittleFS mounted at `/`. - **SD card:** FAT, mounted at `/sd`, hot-swappable (section 12). +- **USB flash drive:** FAT, mounted at `/usb`, hot-pluggable (section 12). - **PSRAM:** 8 MB — the Python heap lives here, so large data/images/audio buffers are fine. @@ -1538,6 +1539,16 @@ ls("/sd") f = open("/sd/data.txt", "w"); f.write("hello"); f.close() ``` +A USB flash drive works the same way, mounted at **`/usb`**: plug one into the +host port and it appears (`USB drive mounted at /usb`); unplugging it unmounts +it (`Warning: USB drive removed`). Unlike the SD card, no polling is +involved — USB tells the board directly when a drive appears or disappears. + +```python +ls("/usb") +cp("/usb/prog.py", "/sd") +``` + --- ## 13. File transfer (XMODEM) diff --git a/ports/rp2/boards/PICO_COMPUTER_3/_boot_board.py b/ports/rp2/boards/PICO_COMPUTER_3/_boot_board.py index 6b68d176005..0448859ec5b 100644 --- a/ports/rp2/boards/PICO_COMPUTER_3/_boot_board.py +++ b/ports/rp2/boards/PICO_COMPUTER_3/_boot_board.py @@ -52,6 +52,12 @@ pcsd.start() +# USB flash drive: mount /usb (if one is already plugged in) and react to +# further plug/unplug events -- see pcusb.py for why this needs no poll. +import pcusb + +pcusb.start() + # On-screen console: console() mirrors REPL output to the HDMI screen. import pcconsole diff --git a/ports/rp2/boards/PICO_COMPUTER_3/manifest.py b/ports/rp2/boards/PICO_COMPUTER_3/manifest.py index b03a74dfc92..e24121dce62 100644 --- a/ports/rp2/boards/PICO_COMPUTER_3/manifest.py +++ b/ports/rp2/boards/PICO_COMPUTER_3/manifest.py @@ -19,6 +19,18 @@ # runs the hot-swap removal/insertion poll (replicates MMBasic's CheckSDCard). freeze("$(BOARD_DIR)", "pcsd.py") +# USB flash drive support is provided by the native C machine.USBDrive driver +# (machine_usbdrive.c / usb_msc.c). pcusb.py mounts /usb and reacts to +# USBDrive.on_change -- USB enumeration gives a definitive plug/unplug event, +# so unlike pcsd.py this needs no poll. +freeze("$(BOARD_DIR)", "pcusb.py") + +# BLE keyboard/mouse (HID over GATT) support -- scan/pair/connect on demand +# (unlike pcsd.py/pcusb.py, this doesn't auto-start at boot: pairing is a +# deliberate user action). Feeds reports into keyboard.inject_report() / +# mouse.inject_report(), the same decoders USB keyboards/mice use. +freeze("$(BOARD_DIR)", "pcblekbd.py") + # Shell-style REPL helpers (ls, ...), injected into __main__ by _boot.py. freeze("$(BOARD_DIR)", "pcshell.py") diff --git a/ports/rp2/boards/PICO_COMPUTER_3/mpconfigboard.cmake b/ports/rp2/boards/PICO_COMPUTER_3/mpconfigboard.cmake index 26fb94f3d60..3c7daf7ee82 100644 --- a/ports/rp2/boards/PICO_COMPUTER_3/mpconfigboard.cmake +++ b/ports/rp2/boards/PICO_COMPUTER_3/mpconfigboard.cmake @@ -48,6 +48,11 @@ set(MICROPY_PY_NETWORK_CYW43 ON) set(MICROPY_PY_BLUETOOTH ON) set(MICROPY_BLUETOOTH_BTSTACK ON) set(MICROPY_PY_BLUETOOTH_CYW43 ON) +# Classic Bluetooth A2DP source (stream audio out to a Bluetooth speaker), +# the `btaudio` module -- see CMakeLists.txt for why this needs ENABLE_CLASSIC +# on the shared BTstack/HCI instance rather than pico-sdk's separate +# pico_btstack_classic library. +set(MICROPY_HW_ENABLE_BT_A2DP 1) # Pull in ulab (numpy-like ndarray/linalg) as a user C module for this board. # ulab's own micropython.cmake defines MODULE_ULAB_ENABLED and links itself into diff --git a/ports/rp2/boards/PICO_COMPUTER_3/pcblekbd.py b/ports/rp2/boards/PICO_COMPUTER_3/pcblekbd.py new file mode 100644 index 00000000000..e7549fed8fe --- /dev/null +++ b/ports/rp2/boards/PICO_COMPUTER_3/pcblekbd.py @@ -0,0 +1,296 @@ +# BLE keyboard/mouse support for the Pico Computer 3 -- HID over GATT +# (HOGP), the standard Bluetooth LE profile most modern compact/multi- +# device keyboards and mice use. Pure Python: MicroPython's raw +# `bluetooth` module already has everything needed (central scan/connect, +# GATT client, pairing) built in -- no firmware/C changes required for +# this, unlike Bluetooth Classic audio (see btaudio.c). Reports received +# over BLE are fed into the exact same decoders USB keyboards/mice use +# (keyboard.inject_report() / mouse.inject_report()), so keydown(), +# mouse(), num-lock etc. all work identically regardless of transport. +# +# NOT YET TESTED ON REAL HARDWARE -- written against MicroPython's +# documented raw bluetooth API (docs/library/bluetooth.rst) and the +# Bluetooth SIG's standard HOGP characteristic UUIDs, but there is no BLE +# keyboard/mouse in hand yet to verify against. The GATT descriptor +# handle-range bookkeeping in particular (see _start_next_cccd below) is +# the fiddliest part of hand-rolled GATT client code and the most likely +# place a real device's exact attribute layout trips this up. +# +# Known limitations (v1, deliberately kept simple): +# - One device connected at a time (matches the USB HID model: one +# logical keyboard, one logical mouse). +# - No persistent bonding across reboots -- BTstack (used on this board) +# doesn't support the _IRQ_GET_SECRET/_IRQ_SET_SECRET bonding-store +# events MicroPython's docs say are "NimBLE only". Re-pairing is +# needed after every reboot/reconnect. If a device needs numeric- +# comparison or passkey-entry pairing (not "Just Works"), only numeric +# comparison is auto-accepted here; passkey entry isn't handled (no +# input UI wired up for it yet). +# - Only the fixed-format Boot Keyboard/Mouse Input Report +# characteristics are used, not arbitrary Report-mode descriptors -- +# covers the large majority of real keyboards/mice (boot mode exists +# specifically for simple hosts like this), but a device that only +# implements Report mode won't work without more code. +# - Uses bluetooth.BLE()'s raw single irq() slot directly (no aioble) -- +# this cannot run at the same time as any code that also calls +# aioble/ble.irq() elsewhere in the same program, since only one +# handler can be registered at once. Nothing else in this app uses +# aioble as of writing, so there's no actual conflict yet. +# +# import pcblekbd +# pcblekbd.on_found(lambda addr, addr_type, name, rssi: print(name, rssi)) +# pcblekbd.scan(8000) +# pcblekbd.on_connect(lambda ok: print("connected" if ok else "disconnected")) +# pcblekbd.connect(addr, addr_type) + +import bluetooth +from micropython import const +import keyboard +import mouse + +_IRQ_SCAN_RESULT = const(5) +_IRQ_SCAN_DONE = const(6) +_IRQ_PERIPHERAL_CONNECT = const(7) +_IRQ_PERIPHERAL_DISCONNECT = const(8) +_IRQ_GATTC_SERVICE_RESULT = const(9) +_IRQ_GATTC_SERVICE_DONE = const(10) +_IRQ_GATTC_CHARACTERISTIC_RESULT = const(11) +_IRQ_GATTC_CHARACTERISTIC_DONE = const(12) +_IRQ_GATTC_DESCRIPTOR_RESULT = const(13) +_IRQ_GATTC_DESCRIPTOR_DONE = const(14) +_IRQ_GATTC_WRITE_DONE = const(17) +_IRQ_GATTC_NOTIFY = const(18) +_IRQ_ENCRYPTION_UPDATE = const(28) +_IRQ_PASSKEY_ACTION = const(31) + +_IO_CAPABILITY_NO_INPUT_OUTPUT = const(3) +_PASSKEY_ACTION_NUMERIC_COMPARISON = const(4) + +_UUID_HID_SERVICE = bluetooth.UUID(0x1812) +_UUID_PROTOCOL_MODE = bluetooth.UUID(0x2A4E) +_UUID_BOOT_KBD_INPUT = bluetooth.UUID(0x2A22) +_UUID_BOOT_MOUSE_INPUT = bluetooth.UUID(0x2A33) +_UUID_CCCD = bluetooth.UUID(0x2902) + +_ble = None +_conn = None # single-connection state dict, or None +_found_cb = None +_connect_cb = None + + +def _ensure_active(): + global _ble + if _ble is None: + _ble = bluetooth.BLE() + if not _ble.active(): + _ble.active(True) + _ble.config(io=_IO_CAPABILITY_NO_INPUT_OUTPUT) + _ble.irq(_irq) + + +# Minimal AD-structure parser: find the complete/short local name, if any, +# in a scan result's advertising data. +def _name_from_adv(adv_data): + i = 0 + n = len(adv_data) + while i + 1 < n: + length = adv_data[i] + if length == 0: + break + ad_type = adv_data[i + 1] + if ad_type in (0x09, 0x08) and i + 1 + length <= n: + return bytes(adv_data[i + 2:i + 1 + length]).decode("utf-8", "ignore") + i += 1 + length + return None + + +def _notify_connect(ok): + if _connect_cb is not None: + _connect_cb(ok) + + +# Kick off (or continue) descriptor discovery for whichever HOGP +# characteristic is next in st["cccd_queue"] -- one at a time, since a +# discovered CCCD isn't otherwise attributable to a particular +# characteristic. Range is [value_handle+1, next_higher_value_handle-1] +# (or the service's end_handle for the last characteristic), computed from +# every characteristic handle seen in the service, not just the ones this +# module cares about -- descriptors belong to whichever characteristic +# declaration precedes them, and other HOGP characteristics (Report Map, +# HID Information, ...) sit between them in the handle space. +def _start_next_cccd(st): + if not st["cccd_queue"]: + _notify_connect(True) + return + role, value_handle = st["cccd_queue"][0] + higher = [h for h in st["all_value_handles"] if h > value_handle] + upper = (min(higher) - 1) if higher else st["hid_end_handle"] + st["cccd_role"] = role + st["cccd_found"] = None + if upper > value_handle: + _ble.gattc_discover_descriptors(st["conn_handle"], value_handle + 1, upper) + else: + # No room for a descriptor -- skip straight to the next target. + st["cccd_queue"].pop(0) + _start_next_cccd(st) + + +def _irq(event, data): + global _conn + try: + if event == _IRQ_SCAN_RESULT: + addr_type, addr, adv_type, rssi, adv_data = data + if _found_cb is not None: + _found_cb(bytes(addr), addr_type, _name_from_adv(adv_data), rssi) + + elif event == _IRQ_SCAN_DONE: + pass + + elif event == _IRQ_PERIPHERAL_CONNECT: + conn_handle, addr_type, addr = data + _conn = { + "conn_handle": conn_handle, + "all_value_handles": [], + "kbd_value_handle": None, + "mouse_value_handle": None, + "protocol_mode_handle": None, + "hid_start_handle": None, + "hid_end_handle": None, + "cccd_queue": [], + } + _ble.gap_pair(conn_handle) + + elif event == _IRQ_PERIPHERAL_DISCONNECT: + conn_handle, addr_type, addr = data + if _conn is not None and _conn["conn_handle"] == conn_handle: + _conn = None + _notify_connect(False) + + elif event == _IRQ_ENCRYPTION_UPDATE: + conn_handle, encrypted, authenticated, bonded, key_size = data + if encrypted and _conn is not None and _conn["conn_handle"] == conn_handle: + _ble.gattc_discover_services(conn_handle) + + elif event == _IRQ_PASSKEY_ACTION: + conn_handle, action, passkey = data + # "Just Works" pairing needs no response at all. Numeric + # comparison is auto-accepted (no display to show the number + # on either side to compare); passkey entry/display actions + # are left unhandled -- no input UI wired up for those yet. + if action == _PASSKEY_ACTION_NUMERIC_COMPARISON: + _ble.gap_passkey(conn_handle, action, 1) + + elif event == _IRQ_GATTC_SERVICE_RESULT: + conn_handle, start_handle, end_handle, uuid = data + if _conn is not None and _conn["conn_handle"] == conn_handle and uuid == _UUID_HID_SERVICE: + _conn["hid_start_handle"] = start_handle + _conn["hid_end_handle"] = end_handle + + elif event == _IRQ_GATTC_SERVICE_DONE: + conn_handle, status = data + if _conn is not None and _conn["conn_handle"] == conn_handle and _conn["hid_start_handle"] is not None: + _ble.gattc_discover_characteristics(conn_handle, _conn["hid_start_handle"], _conn["hid_end_handle"]) + + elif event == _IRQ_GATTC_CHARACTERISTIC_RESULT: + conn_handle, end_handle, value_handle, properties, uuid = data + if _conn is None or _conn["conn_handle"] != conn_handle: + return + _conn["all_value_handles"].append(value_handle) + if uuid == _UUID_BOOT_KBD_INPUT: + _conn["kbd_value_handle"] = value_handle + elif uuid == _UUID_BOOT_MOUSE_INPUT: + _conn["mouse_value_handle"] = value_handle + elif uuid == _UUID_PROTOCOL_MODE: + _conn["protocol_mode_handle"] = value_handle + + elif event == _IRQ_GATTC_CHARACTERISTIC_DONE: + conn_handle, status = data + if _conn is None or _conn["conn_handle"] != conn_handle: + return + if _conn["protocol_mode_handle"] is not None: + # Switch the device into Boot Protocol Mode (0x00) so the + # fixed-format boot report characteristics are the ones + # that actually notify. No-op / harmless if the write is + # rejected -- some devices only ever run in boot mode and + # don't expose Protocol Mode at all. + _ble.gattc_write(conn_handle, _conn["protocol_mode_handle"], b"\x00", 1) + if _conn["kbd_value_handle"] is not None: + _conn["cccd_queue"].append(("kbd", _conn["kbd_value_handle"])) + if _conn["mouse_value_handle"] is not None: + _conn["cccd_queue"].append(("mouse", _conn["mouse_value_handle"])) + if _conn["cccd_queue"]: + _start_next_cccd(_conn) + else: + _notify_connect(False) # HID service had neither -- nothing to do + + elif event == _IRQ_GATTC_DESCRIPTOR_RESULT: + conn_handle, dsc_handle, uuid = data + if _conn is not None and _conn["conn_handle"] == conn_handle and uuid == _UUID_CCCD: + _conn["cccd_found"] = dsc_handle + + elif event == _IRQ_GATTC_DESCRIPTOR_DONE: + conn_handle, status = data + if _conn is None or _conn["conn_handle"] != conn_handle: + return + if _conn["cccd_found"] is not None: + _ble.gattc_write(conn_handle, _conn["cccd_found"], b"\x01\x00", 1) + _conn["cccd_queue"].pop(0) + _start_next_cccd(_conn) + + elif event == _IRQ_GATTC_WRITE_DONE: + pass + + elif event == _IRQ_GATTC_NOTIFY: + conn_handle, value_handle, notify_data = data + if _conn is None or _conn["conn_handle"] != conn_handle: + return + if value_handle == _conn["kbd_value_handle"]: + keyboard.inject_report(bytes(notify_data)) + elif value_handle == _conn["mouse_value_handle"]: + mouse.inject_report(bytes(notify_data)) + except Exception as e: + # Never let a malformed event from a flaky device kill the whole + # BLE stack's callback dispatch. + print("pcblekbd:", type(e).__name__, e) + + +def on_found(fn): + """fn(addr: bytes, addr_type: int, name: str or None, rssi: int), called + for each device seen during scan().""" + global _found_cb + _found_cb = fn + + +def on_connect(fn): + """fn(connected: bool), called once HID input reports are actually + subscribed and ready (not just "BLE connected").""" + global _connect_cb + _connect_cb = fn + + +def scan(duration_ms=8000): + _ensure_active() + _ble.gap_scan(duration_ms, 30000, 30000, True) + + +def stop_scan(): + if _ble is not None: + _ble.gap_scan(None) + + +def connect(addr, addr_type=0): + """addr: 6-byte bytes from on_found(). addr_type: as given by on_found + (0 = public, 1 = random -- most BLE peripherals use random).""" + _ensure_active() + _ble.gap_connect(addr_type, addr) + + +def disconnect(): + if _conn is not None: + _ble.gap_disconnect(_conn["conn_handle"]) + + +def connected(): + if _conn is None: + return False + return _conn.get("kbd_value_handle") is not None or _conn.get("mouse_value_handle") is not None diff --git a/ports/rp2/boards/PICO_COMPUTER_3/pcusb.py b/ports/rp2/boards/PICO_COMPUTER_3/pcusb.py new file mode 100644 index 00000000000..eb01e4b30ae --- /dev/null +++ b/ports/rp2/boards/PICO_COMPUTER_3/pcusb.py @@ -0,0 +1,79 @@ +# Hot-swappable USB flash drive support for the Pico Computer 3. +# +# Unlike the SD card -- a bare SPI card with no card-detect signal, so +# pcsd.py polls it twice a second -- USB enumeration tells the firmware +# definitively when a drive appears or disappears (machine.USBDrive.on_change, +# C side in usb_msc.c). This module just mounts/unmounts /usb in response to +# that event, reading the new state back from USBDrive().present() -- so, +# unlike pcsd.py, there is no timer and no explicit reinit() step. + +import machine +import vfs + +_MOUNT = "/usb" + +_drive = None +_mounted = False +verbose = True # print a notice when a drive is removed / inserted + + +def mounted(): + return _mounted + + +def _mount(): + global _mounted + try: + vfs.mount(vfs.VfsFat(_drive), _MOUNT) + _mounted = True + if verbose: + print("\nUSB drive mounted at", _MOUNT) + return True + except Exception: + return False + + +def _umount(): + global _mounted + try: + vfs.umount(_MOUNT) + except Exception: + pass + _mounted = False + + +def _on_change(connected): + # Runs in scheduler context (mp_sched_schedule), like USBSerial.on_change. + # `connected` isn't used directly -- present() is re-checked instead, so + # a stale/coalesced event can't leave the mount state wrong. + try: + if _drive.present(): + if not _mounted: + _mount() + elif _mounted: + _umount() + if verbose: + print("\nWarning: USB drive removed") + except Exception: + # Never let a transient error kill the callback. + pass + + +def start(): + """Create the USB drive object, mount it now if one is already plugged + in, and register the connect/disconnect handler. Returns True if mounted. + Safe to call more than once.""" + global _drive, _mounted + if _drive is None: + _drive = machine.USBDrive() + machine.USBDrive.on_change(_on_change) + _mounted = False + if _drive.present(): + _mount() + return _mounted + + +def stop(): + """Unregister the connect/disconnect handler (leaves any mount in place).""" + if _drive is not None: + machine.USBDrive.on_change(None) diff --git a/ports/rp2/btaudio.c b/ports/rp2/btaudio.c new file mode 100644 index 00000000000..d92581e7cd8 --- /dev/null +++ b/ports/rp2/btaudio.c @@ -0,0 +1,559 @@ +/* + * This file is part of the MicroPython project, http://micropython.org/ + * + * The MIT License (MIT) + * + * Copyright (c) 2026 Pico Computer 3 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND... + */ + +// `btaudio`: Bluetooth Classic A2DP SOURCE (stream audio OUT to a Bluetooth +// speaker/headset). This is a different Bluetooth transport from the board's +// existing BLE support (aioble, keyboard/mouse) -- A2DP is Classic Bluetooth +// (BR/EDR), which MicroPython's `bluetooth` module has no bindings for at +// all. This file adds just enough of BTstack's own classic stack (already +// vendored in lib/btstack, and already used in full by pico-sdk's own +// pico_btstack_classic target -- this file is adapted directly from BTstack's +// official a2dp_source_demo.c example, trimmed to source-only, no AVRCP) to +// send a live PCM stream fed from Python out over A2DP as SBC audio. +// +// IMPORTANT shared-stack constraint: MicroPython's own `bluetooth` module +// (modbluetooth_btstack.c) already owns hci_init()/l2cap_init()/sm_init() and +// HCI power control (via bluetooth.BLE().active(True)). This file must NOT +// call any of those again -- classic and LE share one BTstack/HCI instance, +// dual-mode by design. btaudio_ensure_init() below only adds the classic-only +// pieces (A2DP source, SDP) on top of whatever's already running, and every +// entry point checks hci_get_state() == HCI_STATE_WORKING first, raising a +// clear error if bluetooth.BLE().active(True) hasn't been called yet. +// +// Not yet tested on real hardware -- written against BTstack's official demo +// and API headers, but there is no Bluetooth speaker in hand yet to verify +// against. Expect this to need iteration (SBC bitpool/buffer tuning, timing) +// once real audio is heard. +// +// import bluetooth +// bluetooth.BLE().active(True) # brings up the shared HCI/L2CAP stack +// import btaudio +// btaudio.on_found(lambda addr, name, rssi: print(name, rssi)) +// btaudio.scan(8000) # 8 second classic inquiry +// btaudio.on_connect(lambda ok: print("connected" if ok else "disconnected")) +// btaudio.connect(addr) # addr: 6-byte bytes from on_found +// ... +// btaudio.write(pcm_bytes) # interleaved 16-bit LE stereo PCM +// btaudio.writable() # free space (bytes) in the feed buffer + +#include "py/runtime.h" +#include "py/mperrno.h" +#include "py/mphal.h" +#include "py/ringbuf.h" + +#if MICROPY_HW_ENABLE_BT_A2DP + +#include +#include "btstack.h" + +#define NUM_CHANNELS 2 +#define BYTES_PER_SAMPLE (2 * NUM_CHANNELS) // 16-bit stereo +#define AUDIO_TIMEOUT_MS 10 +#define SBC_STORAGE_SIZE 1030 +// ~46ms of 44.1kHz 16-bit stereo audio. Kept deliberately small: this +// board's SRAM GC arena is already tuned to a tight minimum (see the +// ASSERT in memmap_rp2350/section_extra_post_platform_end.incl -- adding +// classic Bluetooth's own static buffers already ate most of the slack +// upstream left). Revisit upward only alongside checking that assert still +// holds, and only if real playback shows underruns needing more slack. +#define PCM_RINGBUF_SIZE (4096) + +typedef struct { + uint16_t a2dp_cid; + uint8_t local_seid; + uint8_t remote_seid; + bool stream_opened; + bool streaming; + + uint32_t time_audio_data_sent; // ms + uint32_t acc_num_missed_samples; + uint32_t samples_ready; + btstack_timer_source_t audio_timer; + int max_media_payload_size; + uint32_t rtp_timestamp; + + uint8_t sbc_storage[SBC_STORAGE_SIZE]; + uint16_t sbc_storage_count; + bool sbc_ready_to_send; +} a2dp_media_sending_context_t; + +// SBC 44100 Hz, stereo; block length 16 / subbands 8 / Loudness allocation +// (0xFF byte, per BTstack's own demo -- the sink negotiates the specifics), +// min/max bitpool 2/53. Matches BTstack's a2dp_source_demo.c exactly. +static uint8_t media_sbc_codec_capabilities[] = { + (AVDTP_SBC_44100 << 4) | AVDTP_SBC_STEREO, + 0xFF, + 2, 53, +}; + +static bool btaudio_inited = false; +static a2dp_media_sending_context_t media_tracker; +static btstack_sbc_encoder_state_t sbc_encoder_state; +static avdtp_configuration_sbc_t sbc_configuration; +static uint32_t negotiated_sample_rate; + +static btstack_packet_callback_registration_t hci_event_callback_registration; +static uint8_t sdp_a2dp_source_service_buffer[150]; + +// Raw PCM feed from Python (write()); consumed by the audio timer, which +// pulls exactly the number of samples elapsed real time calls for, per +// BTstack's own pacing approach (a2dp_demo_audio_timeout_handler). Backed by +// a plain byte ring buffer (single producer: write() from the VM; single +// consumer: the BTstack run-loop timer -- both run cooperatively on the one +// core, same safety argument as usb_cdc.c's RX ringbuf). +static ringbuf_t pcm_rb; +static uint8_t pcm_rb_store[PCM_RINGBUF_SIZE]; + +// Rooted (MP_REGISTER_ROOT_POINTER below) so the GC keeps these alive -- +// they're the only reference to the Python callback once registered. +static void notify_connect(bool connected) { + mp_obj_t cb = MP_STATE_PORT(btaudio_connect_cb); + if (cb != MP_OBJ_NULL && cb != mp_const_none) { + mp_sched_schedule(cb, mp_obj_new_bool(connected)); + } +} + +// --- SBC encode + send, ported from a2dp_demo_send_media_packet / +// a2dp_demo_fill_sbc_audio_buffer / a2dp_demo_audio_timeout_handler --------- + +// Pull `num_samples` stereo frames out of the PCM ring buffer into +// `pcm_buffer` (interleaved L/R int16). Zero-fills on underrun rather than +// stalling the timer/encoder -- a brief silence gap is far less disruptive +// than desyncing the whole A2DP timing state machine while Python catches up. +static void pull_pcm(int16_t *pcm_buffer, int num_samples) { + size_t want = (size_t)num_samples * BYTES_PER_SAMPLE; + size_t have = ringbuf_avail(&pcm_rb); + size_t take = (have < want) ? have : want; + uint8_t *dest = (uint8_t *)pcm_buffer; + for (size_t i = 0; i < take; i++) { + dest[i] = (uint8_t)ringbuf_get(&pcm_rb); + } + if (take < want) { + memset(dest + take, 0, want - take); + } +} + +static int fill_sbc_audio_buffer(a2dp_media_sending_context_t *context) { + int total_num_bytes_read = 0; + unsigned int num_audio_samples_per_sbc_buffer = btstack_sbc_encoder_num_audio_frames(); + while (context->samples_ready >= num_audio_samples_per_sbc_buffer + && (unsigned int)(context->max_media_payload_size - context->sbc_storage_count) >= btstack_sbc_encoder_sbc_buffer_length()) { + int16_t pcm_frame[256 * NUM_CHANNELS]; + pull_pcm(pcm_frame, num_audio_samples_per_sbc_buffer); + btstack_sbc_encoder_process_data(pcm_frame); + + uint16_t sbc_frame_size = btstack_sbc_encoder_sbc_buffer_length(); + uint8_t *sbc_frame = btstack_sbc_encoder_sbc_buffer(); + + total_num_bytes_read += num_audio_samples_per_sbc_buffer; + // first byte in sbc storage is the SBC media header (frame count) + memcpy(&context->sbc_storage[1 + context->sbc_storage_count], sbc_frame, sbc_frame_size); + context->sbc_storage_count += sbc_frame_size; + context->samples_ready -= num_audio_samples_per_sbc_buffer; + } + return total_num_bytes_read; +} + +static void send_media_packet(void) { + int num_bytes_in_frame = btstack_sbc_encoder_sbc_buffer_length(); + int bytes_in_storage = media_tracker.sbc_storage_count; + uint8_t num_sbc_frames = bytes_in_storage / num_bytes_in_frame; + media_tracker.sbc_storage[0] = num_sbc_frames; + a2dp_source_stream_send_media_payload_rtp(media_tracker.a2dp_cid, media_tracker.local_seid, 0, + media_tracker.rtp_timestamp, media_tracker.sbc_storage, bytes_in_storage + 1); + + unsigned int num_audio_samples_per_sbc_buffer = btstack_sbc_encoder_num_audio_frames(); + media_tracker.rtp_timestamp += num_sbc_frames * num_audio_samples_per_sbc_buffer; + media_tracker.sbc_storage_count = 0; + media_tracker.sbc_ready_to_send = false; +} + +static void audio_timeout_handler(btstack_timer_source_t *timer) { + a2dp_media_sending_context_t *context = (a2dp_media_sending_context_t *)btstack_run_loop_get_timer_context(timer); + btstack_run_loop_set_timer(&context->audio_timer, AUDIO_TIMEOUT_MS); + btstack_run_loop_add_timer(&context->audio_timer); + uint32_t now = btstack_run_loop_get_time_ms(); + + uint32_t update_period_ms = AUDIO_TIMEOUT_MS; + if (context->time_audio_data_sent > 0) { + update_period_ms = now - context->time_audio_data_sent; + } + + uint32_t num_samples = (update_period_ms * negotiated_sample_rate) / 1000; + context->acc_num_missed_samples += (update_period_ms * negotiated_sample_rate) % 1000; + while (context->acc_num_missed_samples >= 1000) { + num_samples++; + context->acc_num_missed_samples -= 1000; + } + context->time_audio_data_sent = now; + context->samples_ready += num_samples; + + if (context->sbc_ready_to_send) { + return; + } + + fill_sbc_audio_buffer(context); + + if ((unsigned int)(context->sbc_storage_count + btstack_sbc_encoder_sbc_buffer_length()) > (unsigned int)context->max_media_payload_size) { + context->sbc_ready_to_send = true; + a2dp_source_stream_endpoint_request_can_send_now(context->a2dp_cid, context->local_seid); + } +} + +static void audio_timer_start(a2dp_media_sending_context_t *context) { + context->max_media_payload_size = btstack_min(a2dp_max_media_payload_size(context->a2dp_cid, context->local_seid), SBC_STORAGE_SIZE); + context->sbc_storage_count = 0; + context->sbc_ready_to_send = false; + context->streaming = true; + btstack_run_loop_remove_timer(&context->audio_timer); + btstack_run_loop_set_timer_handler(&context->audio_timer, audio_timeout_handler); + btstack_run_loop_set_timer_context(&context->audio_timer, context); + btstack_run_loop_set_timer(&context->audio_timer, AUDIO_TIMEOUT_MS); + btstack_run_loop_add_timer(&context->audio_timer); +} + +static void audio_timer_stop(a2dp_media_sending_context_t *context) { + context->time_audio_data_sent = 0; + context->acc_num_missed_samples = 0; + context->samples_ready = 0; + context->streaming = false; + context->sbc_storage_count = 0; + context->sbc_ready_to_send = false; + btstack_run_loop_remove_timer(&context->audio_timer); +} + +// --- A2DP + classic HCI event handling, ported from a2dp_source_demo.c's +// a2dp_source_packet_handler / hci_packet_handler, AVRCP/reconfigure-free --- + +static void a2dp_source_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *packet, uint16_t size) { + (void)channel; + (void)size; + uint8_t status; + bd_addr_t address; + uint16_t cid; + avdtp_channel_mode_t channel_mode; + uint8_t allocation_method; + + if (packet_type != HCI_EVENT_PACKET) { + return; + } + if (hci_event_packet_get_type(packet) != HCI_EVENT_A2DP_META) { + return; + } + + switch (hci_event_a2dp_meta_get_subevent_code(packet)) { + case A2DP_SUBEVENT_SIGNALING_CONNECTION_ESTABLISHED: + a2dp_subevent_signaling_connection_established_get_bd_addr(packet, address); + cid = a2dp_subevent_signaling_connection_established_get_a2dp_cid(packet); + status = a2dp_subevent_signaling_connection_established_get_status(packet); + if (status != ERROR_CODE_SUCCESS) { + mp_printf(&mp_plat_print, "btaudio: connection failed, status 0x%02x\n", status); + media_tracker.a2dp_cid = 0; + notify_connect(false); + break; + } + media_tracker.a2dp_cid = cid; + mp_printf(&mp_plat_print, "btaudio: signaling connected to %s\n", bd_addr_to_str(address)); + break; + + case A2DP_SUBEVENT_SIGNALING_MEDIA_CODEC_SBC_CONFIGURATION: + cid = avdtp_subevent_signaling_media_codec_sbc_configuration_get_avdtp_cid(packet); + if (cid != media_tracker.a2dp_cid) { + return; + } + media_tracker.remote_seid = a2dp_subevent_signaling_media_codec_sbc_configuration_get_remote_seid(packet); + sbc_configuration.sampling_frequency = a2dp_subevent_signaling_media_codec_sbc_configuration_get_sampling_frequency(packet); + sbc_configuration.block_length = a2dp_subevent_signaling_media_codec_sbc_configuration_get_block_length(packet); + sbc_configuration.subbands = a2dp_subevent_signaling_media_codec_sbc_configuration_get_subbands(packet); + sbc_configuration.min_bitpool_value = a2dp_subevent_signaling_media_codec_sbc_configuration_get_min_bitpool_value(packet); + sbc_configuration.max_bitpool_value = a2dp_subevent_signaling_media_codec_sbc_configuration_get_max_bitpool_value(packet); + channel_mode = (avdtp_channel_mode_t)a2dp_subevent_signaling_media_codec_sbc_configuration_get_channel_mode(packet); + allocation_method = a2dp_subevent_signaling_media_codec_sbc_configuration_get_allocation_method(packet); + + negotiated_sample_rate = sbc_configuration.sampling_frequency; + sbc_configuration.allocation_method = (btstack_sbc_allocation_method_t)(allocation_method - 1); + switch (channel_mode) { + case AVDTP_CHANNEL_MODE_JOINT_STEREO: + sbc_configuration.channel_mode = SBC_CHANNEL_MODE_JOINT_STEREO; + break; + case AVDTP_CHANNEL_MODE_STEREO: + sbc_configuration.channel_mode = SBC_CHANNEL_MODE_STEREO; + break; + case AVDTP_CHANNEL_MODE_DUAL_CHANNEL: + sbc_configuration.channel_mode = SBC_CHANNEL_MODE_DUAL_CHANNEL; + break; + default: + sbc_configuration.channel_mode = SBC_CHANNEL_MODE_MONO; + break; + } + mp_printf(&mp_plat_print, "btaudio: SBC configured, %lu Hz\n", (unsigned long)negotiated_sample_rate); + btstack_sbc_encoder_init(&sbc_encoder_state, SBC_MODE_STANDARD, + sbc_configuration.block_length, sbc_configuration.subbands, + sbc_configuration.allocation_method, sbc_configuration.sampling_frequency, + sbc_configuration.max_bitpool_value, sbc_configuration.channel_mode); + break; + + case A2DP_SUBEVENT_STREAM_ESTABLISHED: + status = a2dp_subevent_stream_established_get_status(packet); + if (status != ERROR_CODE_SUCCESS) { + mp_printf(&mp_plat_print, "btaudio: stream failed, status 0x%02x\n", status); + notify_connect(false); + break; + } + media_tracker.stream_opened = true; + a2dp_source_start_stream(media_tracker.a2dp_cid, media_tracker.local_seid); + break; + + case A2DP_SUBEVENT_STREAM_STARTED: + audio_timer_start(&media_tracker); + mp_printf(&mp_plat_print, "btaudio: streaming\n"); + notify_connect(true); + break; + + case A2DP_SUBEVENT_STREAMING_CAN_SEND_MEDIA_PACKET_NOW: + send_media_packet(); + break; + + case A2DP_SUBEVENT_STREAM_SUSPENDED: + audio_timer_stop(&media_tracker); + break; + + case A2DP_SUBEVENT_STREAM_RELEASED: + cid = a2dp_subevent_stream_released_get_a2dp_cid(packet); + if (cid == media_tracker.a2dp_cid) { + media_tracker.stream_opened = false; + } + audio_timer_stop(&media_tracker); + notify_connect(false); + break; + + case A2DP_SUBEVENT_SIGNALING_CONNECTION_RELEASED: + cid = a2dp_subevent_signaling_connection_released_get_a2dp_cid(packet); + if (cid == media_tracker.a2dp_cid) { + media_tracker.a2dp_cid = 0; + } + break; + + default: + break; + } +} + +// GAP inquiry (device scan) + legacy PIN fallback. Registered on the shared +// HCI event handler list alongside whatever modbluetooth_btstack.c already +// has there -- BTstack supports multiple independent handlers, same pattern +// mp_usbh.c uses for HID/CDC/MSC on the USB host side. +static void hci_packet_handler(uint8_t packet_type, uint16_t channel, uint8_t *packet, uint16_t size) { + (void)channel; + (void)size; + if (packet_type != HCI_EVENT_PACKET) { + return; + } + bd_addr_t address; + + switch (hci_event_packet_get_type(packet)) { + case HCI_EVENT_PIN_CODE_REQUEST: + // Fallback for older/simple speakers still using legacy pairing + // rather than Secure Simple Pairing. "0000" is the de facto + // standard fixed PIN for headset/speaker-class devices. + hci_event_pin_code_request_get_bd_addr(packet, address); + gap_pin_code_response(address, "0000"); + break; + case GAP_EVENT_INQUIRY_RESULT: { + gap_event_inquiry_result_get_bd_addr(packet, address); + mp_obj_t found_cb = MP_STATE_PORT(btaudio_found_cb); + if (found_cb != MP_OBJ_NULL && found_cb != mp_const_none) { + mp_obj_t name_obj = mp_const_none; + if (gap_event_inquiry_result_get_name_available(packet)) { + int name_len = gap_event_inquiry_result_get_name_len(packet); + name_obj = mp_obj_new_str((const char *)gap_event_inquiry_result_get_name(packet), name_len); + } + int8_t rssi = 0; + if (gap_event_inquiry_result_get_rssi_available(packet)) { + rssi = (int8_t)gap_event_inquiry_result_get_rssi(packet); + } + mp_obj_t items[3] = { + mp_obj_new_bytes(address, 6), + name_obj, + MP_OBJ_NEW_SMALL_INT(rssi), + }; + mp_sched_schedule(found_cb, mp_obj_new_tuple(3, items)); + } + break; + } + default: + break; + } +} + +// One-time classic-only setup, added on top of whatever HCI/L2CAP instance +// modbluetooth_btstack.c already brought up. Deliberately does NOT call +// hci_init()/l2cap_init()/hci_power_control() -- see file header. +static void btaudio_ensure_init(void) { + if (btaudio_inited) { + return; + } + + a2dp_source_init(); + a2dp_source_register_packet_handler(&a2dp_source_packet_handler); + + avdtp_stream_endpoint_t *local_stream_endpoint = a2dp_source_create_stream_endpoint( + AVDTP_AUDIO, AVDTP_CODEC_SBC, + media_sbc_codec_capabilities, sizeof(media_sbc_codec_capabilities), + NULL, 0); + if (local_stream_endpoint != NULL) { + media_tracker.local_seid = avdtp_local_seid(local_stream_endpoint); + avdtp_source_register_delay_reporting_category(media_tracker.local_seid); + } + + sdp_init(); + memset(sdp_a2dp_source_service_buffer, 0, sizeof(sdp_a2dp_source_service_buffer)); + a2dp_source_create_sdp_record(sdp_a2dp_source_service_buffer, 0x10001, + AVDTP_SOURCE_FEATURE_MASK_PLAYER, NULL, NULL); + sdp_register_service(sdp_a2dp_source_service_buffer); + + hci_event_callback_registration.callback = &hci_packet_handler; + hci_add_event_handler(&hci_event_callback_registration); + + pcm_rb.buf = pcm_rb_store; + pcm_rb.size = sizeof(pcm_rb_store); + pcm_rb.iget = pcm_rb.iput = 0; + + btaudio_inited = true; +} + +static void raise_if_not_working(void) { + if (hci_get_state() != HCI_STATE_WORKING) { + mp_raise_msg(&mp_type_OSError, MP_ERROR_TEXT("bluetooth.BLE().active(True) must be called first")); + } +} + +// --- Python bindings -------------------------------------------------------- + +static mp_obj_t btaudio_scan(size_t n_args, const mp_obj_t *args) { + raise_if_not_working(); + btaudio_ensure_init(); + mp_int_t duration_ms = (n_args >= 1) ? mp_obj_get_int(args[0]) : 8000; + // BTstack's inquiry duration is in 1.28s units, 1..48. + uint8_t units = (uint8_t)btstack_max(1, btstack_min(48, duration_ms / 1280)); + gap_inquiry_start(units); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(btaudio_scan_obj, 0, 1, btaudio_scan); + +static mp_obj_t btaudio_stop_scan(void) { + gap_inquiry_stop(); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(btaudio_stop_scan_obj, btaudio_stop_scan); + +static mp_obj_t btaudio_on_found(mp_obj_t fn) { + MP_STATE_PORT(btaudio_found_cb) = (fn == mp_const_none) ? MP_OBJ_NULL : fn; + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(btaudio_on_found_obj, btaudio_on_found); + +static mp_obj_t btaudio_on_connect(mp_obj_t fn) { + MP_STATE_PORT(btaudio_connect_cb) = (fn == mp_const_none) ? MP_OBJ_NULL : fn; + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(btaudio_on_connect_obj, btaudio_on_connect); + +static mp_obj_t btaudio_connect(mp_obj_t addr_obj) { + raise_if_not_working(); + btaudio_ensure_init(); + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(addr_obj, &bufinfo, MP_BUFFER_READ); + if (bufinfo.len != 6) { + mp_raise_ValueError(MP_ERROR_TEXT("address must be 6 bytes")); + } + bd_addr_t addr; + memcpy(addr, bufinfo.buf, 6); + uint8_t status = a2dp_source_establish_stream(addr, &media_tracker.a2dp_cid); + if (status != ERROR_CODE_SUCCESS) { + mp_raise_msg_varg(&mp_type_OSError, MP_ERROR_TEXT("a2dp connect failed, status 0x%02x"), status); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(btaudio_connect_obj, btaudio_connect); + +static mp_obj_t btaudio_disconnect(void) { + if (media_tracker.a2dp_cid) { + a2dp_source_disconnect(media_tracker.a2dp_cid); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(btaudio_disconnect_obj, btaudio_disconnect); + +static mp_obj_t btaudio_connected(void) { + return mp_obj_new_bool(media_tracker.streaming); +} +static MP_DEFINE_CONST_FUN_OBJ_0(btaudio_connected_obj, btaudio_connected); + +static mp_obj_t btaudio_sample_rate(void) { + return MP_OBJ_NEW_SMALL_INT(media_tracker.streaming ? negotiated_sample_rate : 0); +} +static MP_DEFINE_CONST_FUN_OBJ_0(btaudio_sample_rate_obj, btaudio_sample_rate); + +// write(buf) -- feed interleaved 16-bit LE stereo PCM. Returns bytes accepted +// (less than len(buf) if the feed buffer is full; caller should back off and +// retry, checking writable() to avoid busy-looping). +static mp_obj_t btaudio_write(mp_obj_t buf_obj) { + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(buf_obj, &bufinfo, MP_BUFFER_READ); + const uint8_t *src = bufinfo.buf; + size_t n = 0; + while (n < bufinfo.len && ringbuf_put(&pcm_rb, src[n]) >= 0) { + n++; + } + return MP_OBJ_NEW_SMALL_INT(n); +} +static MP_DEFINE_CONST_FUN_OBJ_1(btaudio_write_obj, btaudio_write); + +static mp_obj_t btaudio_writable(void) { + return MP_OBJ_NEW_SMALL_INT(ringbuf_free(&pcm_rb)); +} +static MP_DEFINE_CONST_FUN_OBJ_0(btaudio_writable_obj, btaudio_writable); + +static const mp_rom_map_elem_t btaudio_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_btaudio) }, + { MP_ROM_QSTR(MP_QSTR_scan), MP_ROM_PTR(&btaudio_scan_obj) }, + { MP_ROM_QSTR(MP_QSTR_stop_scan), MP_ROM_PTR(&btaudio_stop_scan_obj) }, + { MP_ROM_QSTR(MP_QSTR_on_found), MP_ROM_PTR(&btaudio_on_found_obj) }, + { MP_ROM_QSTR(MP_QSTR_on_connect), MP_ROM_PTR(&btaudio_on_connect_obj) }, + { MP_ROM_QSTR(MP_QSTR_connect), MP_ROM_PTR(&btaudio_connect_obj) }, + { MP_ROM_QSTR(MP_QSTR_disconnect), MP_ROM_PTR(&btaudio_disconnect_obj) }, + { MP_ROM_QSTR(MP_QSTR_connected), MP_ROM_PTR(&btaudio_connected_obj) }, + { MP_ROM_QSTR(MP_QSTR_sample_rate), MP_ROM_PTR(&btaudio_sample_rate_obj) }, + { MP_ROM_QSTR(MP_QSTR_write), MP_ROM_PTR(&btaudio_write_obj) }, + { MP_ROM_QSTR(MP_QSTR_writable), MP_ROM_PTR(&btaudio_writable_obj) }, +}; +static MP_DEFINE_CONST_DICT(btaudio_module_globals, btaudio_module_globals_table); + +const mp_obj_module_t btaudio_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&btaudio_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_btaudio, btaudio_module); +MP_REGISTER_ROOT_POINTER(mp_obj_t btaudio_found_cb); +MP_REGISTER_ROOT_POINTER(mp_obj_t btaudio_connect_cb); + +#endif // MICROPY_HW_ENABLE_BT_A2DP diff --git a/ports/rp2/machine_usbdrive.c b/ports/rp2/machine_usbdrive.c new file mode 100644 index 00000000000..f4b953de7e5 --- /dev/null +++ b/ports/rp2/machine_usbdrive.c @@ -0,0 +1,170 @@ +/* + * This file is part of the MicroPython project, http://micropython.org/ + * + * The MIT License (MIT) + * + * Copyright (c) 2026 Pico Computer 3 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND... + */ + +// machine.USBDrive: a USB flash drive plugged into the host port, exposed as +// a block device suitable for mounting with vfs.VfsFat -- the USB counterpart +// to machine.SDCard. The TinyUSB MSC-host glue (mount tracking + blocking +// read10/write10) lives in usb_msc.c; this is the QSTR-scanned Python +// surface, structured the same way machine_sdcard.c is. +// +// Unlike the SD card -- a bare SPI card with no card-detect signal, so +// pcsd.py polls it twice a second -- USB enumeration tells the firmware +// definitively when a drive appears or disappears. So instead of a +// present()/check()/reinit() poll trio, USBDrive offers on_change(fn): fn() +// is called on any plug/unplug, and the handler reads present() for the new +// state, exactly like USBSerial.on_change(). +// +// d = machine.USBDrive() +// if d.present(): +// vfs.mount(vfs.VfsFat(d), "/usb") +// machine.USBDrive.on_change(lambda connected: ...) + +#include "py/runtime.h" +#include "py/mperrno.h" +#include "extmod/vfs.h" + +#if MICROPY_HW_USB_HOST + +#include "usb_msc.h" + +// The connect/disconnect callback (usb_msc_change_cb) is registered here +// because this file is QSTR/root-pointer scanned; used from usb_msc.c via +// MP_STATE_PORT, the same split usb_cdc.c/usb_cdc_mod.c use for USBSerial. +MP_REGISTER_ROOT_POINTER(mp_obj_t usb_msc_change_cb); + +extern const mp_obj_type_t machine_usbdrive_type; + +typedef struct _machine_usbdrive_obj_t { + mp_obj_base_t base; +} machine_usbdrive_obj_t; + +// There is only ever one USB drive slot (CFG_TUH_MSC_MAXLUN is 1, one device +// at a time), so use a singleton, exactly like machine.SDCard. +static const machine_usbdrive_obj_t machine_usbdrive_obj = { + .base = { &machine_usbdrive_type }, +}; + +static mp_obj_t machine_usbdrive_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { + (void)type; + mp_arg_check_num(n_args, n_kw, 0, 0, false); + usb_msc_init(); + return MP_OBJ_FROM_PTR(&machine_usbdrive_obj); +} + +// present() -> True if a USB drive is currently enumerated and ready. +static mp_obj_t machine_usbdrive_present(mp_obj_t self_in) { + (void)self_in; + return mp_obj_new_bool(usb_msc_present()); +} +static MP_DEFINE_CONST_FUN_OBJ_1(machine_usbdrive_present_obj, machine_usbdrive_present); + +// info() -> (capacity_in_bytes, block_size), or None if nothing is plugged in. +static mp_obj_t machine_usbdrive_info(mp_obj_t self_in) { + (void)self_in; + if (!usb_msc_present()) { + return mp_const_none; + } + mp_obj_t tuple[2] = { + mp_obj_new_int_from_ull((uint64_t)usb_msc_block_count() * usb_msc_block_size()), + MP_OBJ_NEW_SMALL_INT(usb_msc_block_size()), + }; + return mp_obj_new_tuple(2, tuple); +} +static MP_DEFINE_CONST_FUN_OBJ_1(machine_usbdrive_info_obj, machine_usbdrive_info); + +// USBDrive.on_change(fn) -- fn(connected) called on any USB-drive connect/ +// disconnect (True/False); the handler can also just check USBDrive().present() +// for the current state, exactly like USBSerial.on_change(). A single +// (module-wide) callback. +static mp_obj_t machine_usbdrive_on_change(mp_obj_t fn) { + usb_msc_set_change_cb(fn == mp_const_none ? MP_OBJ_NULL : fn); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(machine_usbdrive_on_change_obj, machine_usbdrive_on_change); +static MP_DEFINE_CONST_STATICMETHOD_OBJ(machine_usbdrive_on_change_staticmethod_obj, MP_ROM_PTR(&machine_usbdrive_on_change_obj)); + +// --- Block device protocol (same shape as machine.SDCard) ------------------- + +static mp_obj_t machine_usbdrive_readblocks(mp_obj_t self_in, mp_obj_t block_num, mp_obj_t buf) { + (void)self_in; + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(buf, &bufinfo, MP_BUFFER_WRITE); + uint32_t block_size = usb_msc_block_size(); + if (block_size == 0) { + return MP_OBJ_NEW_SMALL_INT(-MP_EIO); + } + bool ok = usb_msc_read10(mp_obj_get_int(block_num), (uint16_t)(bufinfo.len / block_size), bufinfo.buf); + return MP_OBJ_NEW_SMALL_INT(ok ? 0 : -MP_EIO); +} +static MP_DEFINE_CONST_FUN_OBJ_3(machine_usbdrive_readblocks_obj, machine_usbdrive_readblocks); + +static mp_obj_t machine_usbdrive_writeblocks(mp_obj_t self_in, mp_obj_t block_num, mp_obj_t buf) { + (void)self_in; + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(buf, &bufinfo, MP_BUFFER_READ); + uint32_t block_size = usb_msc_block_size(); + if (block_size == 0) { + return MP_OBJ_NEW_SMALL_INT(-MP_EIO); + } + bool ok = usb_msc_write10(mp_obj_get_int(block_num), (uint16_t)(bufinfo.len / block_size), bufinfo.buf); + return MP_OBJ_NEW_SMALL_INT(ok ? 0 : -MP_EIO); +} +static MP_DEFINE_CONST_FUN_OBJ_3(machine_usbdrive_writeblocks_obj, machine_usbdrive_writeblocks); + +static mp_obj_t machine_usbdrive_ioctl(mp_obj_t self_in, mp_obj_t cmd_in, mp_obj_t arg_in) { + (void)self_in; + (void)arg_in; + mp_int_t cmd = mp_obj_get_int(cmd_in); + switch (cmd) { + case MP_BLOCKDEV_IOCTL_INIT: + return MP_OBJ_NEW_SMALL_INT(usb_msc_present() ? 0 : -MP_EIO); + case MP_BLOCKDEV_IOCTL_DEINIT: + case MP_BLOCKDEV_IOCTL_SYNC: + return MP_OBJ_NEW_SMALL_INT(0); + case MP_BLOCKDEV_IOCTL_BLOCK_COUNT: + return MP_OBJ_NEW_SMALL_INT(usb_msc_present() ? (mp_int_t)usb_msc_block_count() : -MP_EIO); + case MP_BLOCKDEV_IOCTL_BLOCK_SIZE: + return MP_OBJ_NEW_SMALL_INT(usb_msc_present() ? (mp_int_t)usb_msc_block_size() : -MP_EIO); + default: + return mp_const_none; + } +} +static MP_DEFINE_CONST_FUN_OBJ_3(machine_usbdrive_ioctl_obj, machine_usbdrive_ioctl); + +static const mp_rom_map_elem_t machine_usbdrive_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_present), MP_ROM_PTR(&machine_usbdrive_present_obj) }, + { MP_ROM_QSTR(MP_QSTR_info), MP_ROM_PTR(&machine_usbdrive_info_obj) }, + { MP_ROM_QSTR(MP_QSTR_on_change), MP_ROM_PTR(&machine_usbdrive_on_change_staticmethod_obj) }, + // Block device protocol. + { MP_ROM_QSTR(MP_QSTR_readblocks), MP_ROM_PTR(&machine_usbdrive_readblocks_obj) }, + { MP_ROM_QSTR(MP_QSTR_writeblocks), MP_ROM_PTR(&machine_usbdrive_writeblocks_obj) }, + { MP_ROM_QSTR(MP_QSTR_ioctl), MP_ROM_PTR(&machine_usbdrive_ioctl_obj) }, +}; +static MP_DEFINE_CONST_DICT(machine_usbdrive_locals_dict, machine_usbdrive_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + machine_usbdrive_type, + MP_QSTR_USBDrive, + MP_TYPE_FLAG_NONE, + make_new, machine_usbdrive_make_new, + locals_dict, &machine_usbdrive_locals_dict + ); + +#endif // MICROPY_HW_USB_HOST diff --git a/ports/rp2/modmachine.c b/ports/rp2/modmachine.c index b77a7970def..d24c7eb362c 100644 --- a/ports/rp2/modmachine.c +++ b/ports/rp2/modmachine.c @@ -65,11 +65,20 @@ extern const mp_obj_type_t machine_sdcard_type; #define MICROPY_PY_MACHINE_SDCARD_ENTRY #endif +#if MICROPY_HW_USB_HOST +extern const mp_obj_type_t machine_usbdrive_type; +#define MICROPY_HW_USB_HOST_USBDRIVE_ENTRY \ + { MP_ROM_QSTR(MP_QSTR_USBDrive), MP_ROM_PTR(&machine_usbdrive_type) }, +#else +#define MICROPY_HW_USB_HOST_USBDRIVE_ENTRY +#endif + #define MICROPY_PY_MACHINE_EXTRA_GLOBALS \ { MP_ROM_QSTR(MP_QSTR_Pin), MP_ROM_PTR(&machine_pin_type) }, \ { MP_ROM_QSTR(MP_QSTR_RTC), MP_ROM_PTR(&machine_rtc_type) }, \ { MP_ROM_QSTR(MP_QSTR_Timer), MP_ROM_PTR(&machine_timer_type) }, \ MICROPY_PY_MACHINE_SDCARD_ENTRY \ + MICROPY_HW_USB_HOST_USBDRIVE_ENTRY \ \ { MP_ROM_QSTR(MP_QSTR_PWRON_RESET), MP_ROM_INT(RP2_RESET_PWRON) }, \ { MP_ROM_QSTR(MP_QSTR_WDT_RESET), MP_ROM_INT(RP2_RESET_WDT) }, \ diff --git a/ports/rp2/mp_usbh.c b/ports/rp2/mp_usbh.c index 7565b8d129e..40285b764c7 100644 --- a/ports/rp2/mp_usbh.c +++ b/ports/rp2/mp_usbh.c @@ -37,6 +37,7 @@ #include "usb_mouse.h" // USB mouse support (usb_mouse.c) #include "usb_gamepad.h" // USB HID gamepad support (usb_gamepad.c) #include "usb_cdc.h" // USB CDC (serial) host support (usb_cdc.c) +#include "usb_msc.h" // USB mass-storage (flash drive) host support (usb_msc.c) #include "kbd_decode.h" // the shared HID keyboard decoder static void hid_poll(void); @@ -104,6 +105,7 @@ void mp_usbh_init(void) { } tuh_init(0); // native controller, root-hub port 0 usb_cdc_init(); // set up the CDC receive ring buffers before enumeration + usb_msc_init(); // reset mass-storage host state before enumeration add_repeating_timer_us(-1000, usbh_wake_cb, NULL, &usbh_wake_timer); usbh_inited = true; } diff --git a/ports/rp2/usb_keyboard.c b/ports/rp2/usb_keyboard.c index 5e3ff3cc3f8..530796a7fba 100644 --- a/ports/rp2/usb_keyboard.c +++ b/ports/rp2/usb_keyboard.c @@ -30,6 +30,7 @@ #include #include "keyboard_maps.h" // vendored MMBasic layout tables (US/UK/DE/FR/ES/BE) +#include "kbd_decode.h" // kbd_process_report -- no tusb dependency, unlike mp_usbh.c // Held-key state, maintained by the HID report decoder in mp_usbh.c. extern int usb_kbd_keydown(int n); @@ -207,6 +208,19 @@ static mp_obj_t kbd_on_usb_event(size_t n_args, const mp_obj_t *args) { } static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(kbd_on_usb_event_obj, 0, 1, kbd_on_usb_event); +// keyboard.inject_report(data) -- feed an externally-sourced 8-byte HID boot +// keyboard report (e.g. from a Bluetooth LE HID-over-GATT keyboard, see +// pcbtkbd.py) into the same decoder USB keyboards use. slot -1: no lock-LED +// handling on this path -- a BLE keyboard's LEDs (if it has any) are pushed +// by writing its GATT Report characteristic directly, not through here. +static mp_obj_t kbd_inject_report(mp_obj_t data) { + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(data, &bufinfo, MP_BUFFER_READ); + kbd_process_report(bufinfo.buf, bufinfo.len, -1); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(kbd_inject_report_obj, kbd_inject_report); + static const mp_rom_map_elem_t keyboard_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_keyboard) }, { MP_ROM_QSTR(MP_QSTR_keymap), MP_ROM_PTR(&kbd_keymap_obj) }, @@ -220,6 +234,7 @@ static const mp_rom_map_elem_t keyboard_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_on_numlock), MP_ROM_PTR(&kbd_on_numlock_obj) }, { MP_ROM_QSTR(MP_QSTR_on_key), MP_ROM_PTR(&kbd_on_key_obj) }, { MP_ROM_QSTR(MP_QSTR_on_usb_event), MP_ROM_PTR(&kbd_on_usb_event_obj) }, + { MP_ROM_QSTR(MP_QSTR_inject_report), MP_ROM_PTR(&kbd_inject_report_obj) }, // Key codes reported by keydown(1..6) / on_key for the non-printing keys // (MMBasic's codes, from the vendored layout tables). Shift-variants of some // exist too, e.g. Shift-Down = 0xA1, Shift-F1 = 0xB1. diff --git a/ports/rp2/usb_mouse.c b/ports/rp2/usb_mouse.c index 44878a548c8..27f8d8e9694 100644 --- a/ports/rp2/usb_mouse.c +++ b/ports/rp2/usb_mouse.c @@ -372,12 +372,17 @@ void usb_mouse_mount(uint8_t dev_addr, uint8_t instance, mouse_inst = instance; mouse_present = true; analyze_mouse_descriptor(desc_report, desc_len, &mouse_layout); - // Switch from boot protocol to report protocol so the reports match the - // descriptor layout decoded above. TinyUSB puts boot-capable mice into - // boot protocol during enumeration; MMBasic issues exactly this call from - // its mount callback (USBKeyboard.c mouse path) and it's the only - // mount-time control transfer allowed here. - tuh_hid_set_protocol(dev_addr, instance, HID_PROTOCOL_REPORT); + if (dev_addr != USB_MOUSE_BLE_ADDR) { + // Switch from boot protocol to report protocol so the reports match the + // descriptor layout decoded above. TinyUSB puts boot-capable mice into + // boot protocol during enumeration; MMBasic issues exactly this call from + // its mount callback (USBKeyboard.c mouse path) and it's the only + // mount-time control transfer allowed here. A BLE mouse has no TinyUSB + // device behind it, so there is nothing to issue this to -- its reports + // always arrive already in the descriptor's report format (GATT, not a + // USB boot/report protocol split). + tuh_hid_set_protocol(dev_addr, instance, HID_PROTOCOL_REPORT); + } // Start the cursor centred on the current screen so it's visible. int w = hdmi_get_width(), h = hdmi_get_height(); mouse_ax = (w > 0) ? w / 2 : 0; @@ -401,7 +406,9 @@ void usb_mouse_on_report(uint8_t dev_addr, uint8_t instance, // A device the SET_PROTOCOL didn't stick on still sends boot reports; a // descriptor with no X/Y leaves us nothing better than the boot layout. - bool boot = tuh_hid_get_protocol(dev_addr, instance) == HID_PROTOCOL_BOOT; + // A BLE mouse (see USB_MOUSE_BLE_ADDR) is never in USB boot protocol. + bool boot = (dev_addr != USB_MOUSE_BLE_ADDR) && + tuh_hid_get_protocol(dev_addr, instance) == HID_PROTOCOL_BOOT; if (boot || !mouse_layout.valid) { if (len < 3) { return; diff --git a/ports/rp2/usb_mouse.h b/ports/rp2/usb_mouse.h index f9259910620..df8602f0fd9 100644 --- a/ports/rp2/usb_mouse.h +++ b/ports/rp2/usb_mouse.h @@ -29,6 +29,13 @@ enum { int usb_mouse_query(int code); +// Sentinel dev_addr for a non-USB (e.g. Bluetooth LE HID-over-GATT) mouse fed +// in via mouse.inject_mount()/inject_report() (usb_mouse_mod.c). TinyUSB +// addresses are small positive integers (<= CFG_TUH_DEVICE_MAX), so this can +// never collide with a real device; usb_mouse.c skips its TinyUSB-only control +// transfers (set/get protocol) when it sees this address. +#define USB_MOUSE_BLE_ADDR 0xFE + // --- called from the mp_usbh.c HID callbacks (USB / thread context) --------- void usb_mouse_mount(uint8_t dev_addr, uint8_t instance, const uint8_t *desc_report, uint16_t desc_len); diff --git a/ports/rp2/usb_mouse_mod.c b/ports/rp2/usb_mouse_mod.c index fff88836aa0..b94c9f98e6e 100644 --- a/ports/rp2/usb_mouse_mod.c +++ b/ports/rp2/usb_mouse_mod.c @@ -62,10 +62,42 @@ static mp_obj_t mouse_speed(size_t n_args, const mp_obj_t *args) { } static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(mouse_speed_obj, 0, 1, mouse_speed); +// mouse.inject_mount(desc) / inject_report(data) / inject_umount() -- feed a +// non-USB mouse (e.g. Bluetooth LE HID-over-GATT, see pcbtkbd.py) through the +// same descriptor-driven decoder USB mice use: desc is the HID report +// descriptor from the GATT Report Map characteristic (byte-for-byte the same +// language a USB mouse's descriptor uses), data is one Report-characteristic +// notification. All three use the fixed USB_MOUSE_BLE_ADDR sentinel so a real +// USB mouse and a BLE one are never confused with each other. +static mp_obj_t mouse_inject_mount(mp_obj_t desc) { + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(desc, &bufinfo, MP_BUFFER_READ); + usb_mouse_mount(USB_MOUSE_BLE_ADDR, 0, bufinfo.buf, bufinfo.len); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(mouse_inject_mount_obj, mouse_inject_mount); + +static mp_obj_t mouse_inject_report(mp_obj_t data) { + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(data, &bufinfo, MP_BUFFER_READ); + usb_mouse_on_report(USB_MOUSE_BLE_ADDR, 0, bufinfo.buf, bufinfo.len); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(mouse_inject_report_obj, mouse_inject_report); + +static mp_obj_t mouse_inject_umount(void) { + usb_mouse_on_umount(USB_MOUSE_BLE_ADDR, 0); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mouse_inject_umount_obj, mouse_inject_umount); + static const mp_rom_map_elem_t mouse_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_mouse) }, { MP_ROM_QSTR(MP_QSTR_query), MP_ROM_PTR(&mouse_query_obj) }, { MP_ROM_QSTR(MP_QSTR_speed), MP_ROM_PTR(&mouse_speed_obj) }, + { MP_ROM_QSTR(MP_QSTR_inject_mount), MP_ROM_PTR(&mouse_inject_mount_obj) }, + { MP_ROM_QSTR(MP_QSTR_inject_report), MP_ROM_PTR(&mouse_inject_report_obj) }, + { MP_ROM_QSTR(MP_QSTR_inject_umount), MP_ROM_PTR(&mouse_inject_umount_obj) }, }; static MP_DEFINE_CONST_DICT(mouse_module_globals, mouse_module_globals_table); diff --git a/ports/rp2/usb_msc.c b/ports/rp2/usb_msc.c new file mode 100644 index 00000000000..93a24c461e1 --- /dev/null +++ b/ports/rp2/usb_msc.c @@ -0,0 +1,161 @@ +/* + * This file is part of the MicroPython project, http://micropython.org/ + * + * The MIT License (MIT) + * + * Copyright (c) 2026 Pico Computer 3 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND... + */ + +// USB mass-storage (flash drive) host for the Pico Computer 3. A drive plugged +// into the host port enumerates through TinyUSB's msch class driver, which +// calls tuh_msc_mount_cb/umount_cb (defined here, weak in TinyUSB) once it has +// already read LUN 0's capacity during enumeration -- unlike the SD card +// (a bare SPI card with no card-detect signal, see pcsd.py's poller), USB +// gives a definitive connect/disconnect event, so no polling is needed here. +// +// tuh_msc_read10/write10 are non-blocking and callback-based, but +// machine.USBDrive's readblocks/writeblocks (like machine.SDCard's) must +// return synchronously, so usb_msc_read10/write10 below pump tuh_task() in a +// loop until the completion callback fires or a timeout elapses. This is safe +// to call directly (rather than through mp_usbh_task()) because readblocks/ +// writeblocks run to completion inside a single VM call, never re-entering +// the idle event hook that also pumps tuh_task() -- the same non-reentrancy +// argument mp_usbh.c makes for its own guard, just via a different caller. + +#include "py/runtime.h" +#include "py/mphal.h" + +#if MICROPY_HW_USB_HOST + +#include "tusb.h" +#include "usb_msc.h" + +static volatile bool msc_mounted = false; +static uint8_t msc_dev_addr; + +// Set by msc_xfer_complete_cb; polled by msc_wait(). xfer_ok is only +// meaningful once xfer_done is true. Single-core, cooperatively scheduled -- +// the completion callback fires synchronously from within the tuh_task() call +// msc_wait() itself makes, so plain volatiles are enough (as elsewhere in this +// port's USB host code). +static volatile bool xfer_done; +static volatile bool xfer_ok; + +// A read10/write10 completes after several USB transfers (CBW, data, CSW), +// each pumped from tuh_task(); generous for a slow/hub-connected drive +// without hanging forever on one that dropped off the bus mid-command (in +// which case TinyUSB never calls the completion callback at all). +#define MSC_XFER_TIMEOUT_MS 3000 + +void usb_msc_init(void) { + // Nothing to prime ahead of time -- all state is set from the mount + // callbacks below, exactly like usb_cdc's per-interface state. +} + +// Called as cb(True) on mount / cb(False) on unmount -- mp_sched_schedule +// always calls its target with exactly one argument, so the callback (see +// pcusb.py) must take one positional parameter, matching keyboard's +// on_usb_event(connect) and usb_cdc's on_change(idx) conventions elsewhere +// in this port. +static void notify_change(bool connected) { + mp_obj_t cb = MP_STATE_PORT(usb_msc_change_cb); + if (cb != MP_OBJ_NULL && cb != mp_const_none) { + mp_sched_schedule(cb, mp_obj_new_bool(connected)); + } +} + +// --- TinyUSB MSC host callbacks (weak in TinyUSB; defined here) ------------ + +void tuh_msc_mount_cb(uint8_t dev_addr) { + msc_dev_addr = dev_addr; + msc_mounted = true; + mp_printf(&mp_plat_print, "USB drive: mounted addr=%u lun0 %lu x %lu bytes\n", + dev_addr, + (unsigned long)tuh_msc_get_block_count(dev_addr, 0), + (unsigned long)tuh_msc_get_block_size(dev_addr, 0)); + notify_change(true); +} + +void tuh_msc_umount_cb(uint8_t dev_addr) { + (void)dev_addr; + msc_mounted = false; + mp_printf(&mp_plat_print, "USB drive: unmounted addr=%u\n", dev_addr); + notify_change(false); +} + +// --- API for machine.USBDrive ------------------------------------------ + +bool usb_msc_present(void) { + return msc_mounted && tuh_msc_mounted(msc_dev_addr); +} + +uint32_t usb_msc_block_count(void) { + return usb_msc_present() ? tuh_msc_get_block_count(msc_dev_addr, 0) : 0; +} + +uint32_t usb_msc_block_size(void) { + return usb_msc_present() ? tuh_msc_get_block_size(msc_dev_addr, 0) : 0; +} + +static bool msc_xfer_complete_cb(uint8_t dev_addr, tuh_msc_complete_data_t const *cb_data) { + (void)dev_addr; + xfer_ok = (cb_data->csw->status == 0); + xfer_done = true; + return true; +} + +// Pump tuh_task() until the transfer completes, the drive disappears, or the +// timeout elapses. +static bool msc_wait(void) { + uint32_t start = mp_hal_ticks_ms(); + while (!xfer_done) { + tuh_task(); + if (!usb_msc_present()) { + return false; // unplugged mid-transfer + } + if (mp_hal_ticks_ms() - start > MSC_XFER_TIMEOUT_MS) { + return false; + } + } + return xfer_ok; +} + +bool usb_msc_read10(uint32_t lba, uint16_t count, uint8_t *buf) { + if (!usb_msc_present()) { + return false; + } + xfer_done = false; + if (!tuh_msc_read10(msc_dev_addr, 0, buf, lba, count, msc_xfer_complete_cb, 0)) { + return false; + } + return msc_wait(); +} + +bool usb_msc_write10(uint32_t lba, uint16_t count, const uint8_t *buf) { + if (!usb_msc_present()) { + return false; + } + xfer_done = false; + if (!tuh_msc_write10(msc_dev_addr, 0, buf, lba, count, msc_xfer_complete_cb, 0)) { + return false; + } + return msc_wait(); +} + +void usb_msc_set_change_cb(mp_obj_t cb) { + MP_STATE_PORT(usb_msc_change_cb) = cb; +} + +#endif // MICROPY_HW_USB_HOST diff --git a/ports/rp2/usb_msc.h b/ports/rp2/usb_msc.h new file mode 100644 index 00000000000..0861286ddcb --- /dev/null +++ b/ports/rp2/usb_msc.h @@ -0,0 +1,40 @@ +/* + * USB mass-storage (flash drive) host support for the Pico Computer 3. + * + * A USB flash drive plugged into the host port is presented to MicroPython as + * a `machine.USBDrive` block device (machine_usbdrive.c), mountable with + * vfs.VfsFat exactly like machine.SDCard. The TinyUSB MSC-host glue (mount + * tracking + blocking read10/write10 wrappers) lives in usb_msc.c; this + * header is the tusb-free surface shared with machine_usbdrive.c, matching + * the split between usb_cdc.c and the USBSerial object. + */ +#ifndef MICROPY_INCLUDED_RP2_USB_MSC_H +#define MICROPY_INCLUDED_RP2_USB_MSC_H + +#include +#include +#include "py/obj.h" + +// Reset host-side state. Called once from mp_usbh_init(). +void usb_msc_init(void); + +// Is a mass-storage device currently mounted (enumerated + ready) on LUN 0? +bool usb_msc_present(void); + +// Block geometry of the mounted device's LUN 0. Only meaningful while +// usb_msc_present() is true; both return 0 otherwise. +uint32_t usb_msc_block_count(void); +uint32_t usb_msc_block_size(void); + +// Blocking read/write of `count` blocks starting at `lba` on LUN 0. TinyUSB's +// tuh_msc_read10/write10 are callback-based (non-blocking); these pump +// tuh_task() internally until the SCSI command completes or times out. +// Returns true on success. +bool usb_msc_read10(uint32_t lba, uint16_t count, uint8_t *buf); +bool usb_msc_write10(uint32_t lba, uint16_t count, const uint8_t *buf); + +// Register a callback fired as cb(connected) on mount (True) and unmount +// (False). NULL/None clears it. +void usb_msc_set_change_cb(mp_obj_t cb); + +#endif // MICROPY_INCLUDED_RP2_USB_MSC_H diff --git a/shared/tinyusb/tusb_config.h b/shared/tinyusb/tusb_config.h index 9945a75c1a1..805a0dc152b 100644 --- a/shared/tinyusb/tusb_config.h +++ b/shared/tinyusb/tusb_config.h @@ -238,6 +238,13 @@ enum _USBD_EP { #define CFG_TUH_CDC_LINE_CONTROL_ON_ENUM (0x03) // assert DTR|RTS on enumerate #define CFG_TUH_CDC_LINE_CODING_ON_ENUM { 115200, CDC_LINE_CODING_STOP_BITS_1, CDC_LINE_CODING_PARITY_NONE, 8 } +// USB mass-storage (flash drive) host: a plugged-in USB stick is presented to +// MicroPython as a machine.USBDrive block device (usb_msc.c / +// machine_usbdrive.c), mountable with vfs.VfsFat exactly like machine.SDCard. +// One LUN is enough for the flash drives this board targets. +#define CFG_TUH_MSC (1) +#define CFG_TUH_MSC_MAXLUN (1) + #endif // MICROPY_HW_USB_HOST #endif // MICROPY_INCLUDED_SHARED_TINYUSB_TUSB_CONFIG_H