Skip to content

usbhost, audioif, cameraif and displayif live here now; MicroPython pin moves to v1.30.0-preview-160 - #115

Merged
bdbarnett merged 492 commits into
mainfrom
alignment/usb
Oct 19, 2026
Merged

bdbarnett merged 492 commits into
mainfrom
alignment/usb

Conversation

@bdbarnett

Copy link
Copy Markdown
Contributor

The USB host, audio, camera and display modules are now part of this repo, with their history kept, and build from modules/.

  • modules/usbhost (formerly usbif; the binding is _usbhost), modules/audioif, modules/cameraif (with IMX219 support and auto-exposure) and modules/displayif. Their modules.lock lines are gone, and their CI runs here: usbhost.yml, audioif.yml, displayif.yml, cameraif.yml.
  • The MicroPython pin moves to main at b70ae1aca (v1.30.0-preview-160), with the overlay series regenerated against it.
  • New overlays: streaming endpoints on machine.USBDevice (Ring, stream, feedback, stream stats), UVC payloads and frames of any size, a streaming OUT endpoint feeding a C sink, USBDevice.Ring from C for a USB host's isochronous streams, USBDevice at high speed, and FatFs names in UTF-8 so non-ASCII filenames work.
  • S3 builds get the P4's 64 KB TCP window and receive queues. WIFI6_DEV_KIT_H2 freezes the USB packages and midiports; a board variant may carry a manifest.py.
  • Carries micropython-lib's usb-device-midi 0.1.1 fix: a MIDI burst no longer fills the schedule queue.
  • run_host.py drives the host-side checks from one runner, each with a planted fault.

bdbarnett and others added 30 commits September 15, 2026 23:01
Three bugs, and only the first was the one I set out to fix.

1. mclk_multiple was hard-coded to 512, with a comment claiming that was what
   board_peripherals asked its PWM for -- which stopped being true when the
   board moved to 256fs, and nothing noticed because the two never compared
   notes. It also made 44.1 and 48 kHz impossible: 512fs puts MCLK at 22.6 and
   24.6 MHz, which the P4's I2S PLL rejects. It is a keyword now, defaulting to
   256, and soundcard.py passes the board's published AUDIO_OUT.wire.mck_fs --
   which is the entire point of publishing the wire.

2. The decimator truncated. 48000/rate in integer arithmetic gave N=1 at
   32 kHz, a rate the pump accepted, so a 48 kHz stream went undecimated into a
   32 kHz wire and played about a fifth flat with nothing reporting a fault.
   Rates that do not divide the host rate exactly are refused now.

3. The conversion always emitted mono. It averaged L+R regardless of what the
   sink was opened with, which was correct for every board that existed before
   the audio format contract, because they were all mono. Opened as stereo it
   fed half the samples the hardware was clocking for, and the stream played an
   octave up -- Brad heard 'Alvin and the Chipmunks'. The byte counter agreed
   before the code changed: 95,893 B/s against an expected 192,000.

Measured after, at 48 kHz stereo over two minutes of real playback from
Windows: 22,845,696 bytes at 191,980 B/s against 192,000 nominal, within
0.01%, zero timeouts, zero shed. Correct pitch, no distortion.

A first attempt at (3) added a mono-to-stereo expansion branch that writes two
samples for every one it reads -- in a buffer where input and output are the
same memory. Unreachable while the USB descriptor is fixed at stereo, so it
would have waited quietly for whoever made it configurable. Removed, and
pump_start refuses that combination rather than leaving the trap.
…nnel count

Verified on the P4 with two minutes of real playback from Windows at 48 kHz
stereo: 191,980 B/s against 192,000 nominal, zero timeouts, zero shed, correct
pitch and no distortion by ear.

A separate, pre-existing dropout remains on this path (usbif#13): USB feedback
is disabled by deliberate choice, so host and sink clocks drift. Not caused by
this change and present before it.
…spike

Two things the workspace had no way to do. First, pull an audioif graph
without allocating: audiocore.get_buffer() m_mallocs a copy of every block,
so it fails micropython.heap_lock() before any node gets a chance to, and
the allocation-free claim could only ever be measured, never gated.
audiopump.pull() is the same pull with nothing between it and the protocol.

Second, run that pull on a thread with no interpreter state at all -- a
plain pthread, the unix stand-in for a FreeRTOS task. It resolves the
audiosample protocol once on the interpreter thread and the loop calls
through the resolved pointers, because the funnel every node uses
(audiosample_get_buffer) calls mp_proto_get_or_throw and
audiosample_check_for_deinit, and both of those raise.

Throwaway. One global pump, no ring, no timing, nothing staged.
The pull is wrapped in audioif_pump_lock_acquire_pump/release_pump --
one block, and not the sink write, which blocks for a DMA block and
would make every knob wait for the speaker.

retarget() no longer needs a park. Everything that can raise happens
first on the interpreter thread; then the lock, three stores, unlock,
and the pump sees the whole new tail or the whole old one. That is the
handoff page's registry entry.

The soft-reset type-word net moves inside the lock and after the
retarget is applied: checking it before called every live class swap a
re-inited heap (err=4 on every swap storm).

spawn() refuses a file-backed tail with a sentence. audioif's WaveFile
and MP3Decoder refuse the pump from the inside, so one deeper in a
graph publishes AUDIOIF_PUMP_FAULT_UNPUMPABLE and goes silent instead of
reading a file from a thread that cannot raise.

The status block gains the fault code and the lock's three costs, and
lock_stats() / lock_reset() / fault() read them without a pump running.
audiopump_get calls the resolved protocol directly -- that is the point
of resolving it once on the interpreter thread -- so it bypasses
audiosample_get_buffer and the deinit guard that lives there. The guard
covers every node INSIDE the graph and not the one the pump holds.

A class that deinits its own Mixer between two pulls is legitimate; the
pump writing into that Mixer's freed buffers is not. One compare per
block, and the result is silence with AUDIOIF_PUMP_FAULT_DEINITED
published rather than a write through NULL.
…s of source

audiopump.Ring: Python writes PCM, the pump's thread pulls blocks out
through the same protocol every other node speaks. Because it is a node,
a pushed stream can be the tail by itself or sit behind a Mixer with a
chain of effects on it.

Lock-free SPSC with 32-bit acquire/release indices rather than the 64-bit
volatile counters the output ring uses -- a 64-bit store is two stores on
RV32 and Xtensa, which is a latent tear on both boards we ship. write()
never takes the pump lock; clear() and deinit() do, because both write
the consumer's own state.

Underrun is a whole block of silence, a count, and the data stays in the
ring. Overrun is a short write and a count. Neither raises and neither
blocks.
…e audio clock

Live playing already worked. Sequenced music did not: the drum machine, the
soundtrack player and a MIDI-file player all keep time in Python on the
interpreter thread, so a collection or a redraw lands as a late note.

audiopump.Events is a queue of frame-stamped operations the pump applies at
the top of each block, inside the lock it already holds, in a few stores.
Six operations -- press, release, release_all, play, stop, level -- each one
chosen because its apply half is stores and there is no call beneath it that
can allocate or raise. Everything that could refuse happens at schedule time
on the interpreter thread, including two that are easy to miss: the note's
envelope recalculation, which reads a namedtuple with mp_obj_get_float, and
the level's float box.

audiopump.now() is the clock: frames pulled since spawn(), 32-bit and
wrapping with a release store, for the reason the push ring found. What is
audible is that minus the sink's depth, and the API means the pull.

audiopump.Tap is a lossy ring the pump memcpys its block into after the tail.
Its capacity rounds up to a power of two so the position stays correct across
the counter's wrap.

Also here: the output ring's 64-bit cross-thread indices, which tear on RV32
and Xtensa, are 32-bit atomics now, with the positions tracked separately
rather than recomputed as w % len -- a ring whose length does not divide 2^32
jumps when the counter wraps.

spawn(pace=True) is unix-only and holds each block until its wall-clock
moment. Without it no desktop statement about a late note means anything.
…ping index

w and r are 32-bit and wrapping, which is right. Deriving the buffer
position from them as `w % cap` is not: a capacity that does not divide
2^32 makes that position jump by `2^32 % cap` when the index wraps, and
the write lands on top of frames the consumer has not read. Once per
4 GB, about six hours of 48 kHz stereo -- the working day of a streaming
player. Same trap, same fix as the output ring on spike/lap-events.

docs/spikes/probes/audiodev_ring_wrap.py in the anchor pushes the real
4 GB and shows it: against this file before the fix, in_digest
2ba544f55779a907 and out_digest 2a8ae79dbee91349; after it, both
2ba544f55779a907. Two things the probe needed before it could fail at
all -- the ring has to be HOLDING data at the wrap (one block in, one
block out puts both positions through the same expression and the round
trip survives anything), and the blocks have to differ from each other
(the same 32 kB written over the top of itself destroys nothing a digest
can see). 4.25 GB with neither passed the broken ring.
…inary

MicroPython's windows port has no `_thread` module, so a C pump thread is
not an optimisation there -- it is the only way a second thread exists in
that interpreter. MinGW-w64's gcc on this box is thread-model=win32, so
<pthread.h> compiles and pthread_create does not link, which makes the POSIX
branch a trap rather than a fallback. Hence a third branch.

  _beginthreadex, joined with WaitForSingleObject and closed with
  CloseHandle. A CreateThread handle dropped on the floor is one handle
  leaked per spawn, and a player that stops and starts a note at a time
  spawns per note.

  QueryPerformanceCounter, divided before it is scaled: ticks * 1000000
  overflows a signed 64-bit at about ten days of uptime on a 10 MHz QPC,
  and the pump would then report negative block times. The frequency is
  latched on the interpreter thread in prepare(), before the pump exists.

  pace= on a high-resolution waitable timer. Sleep()'s floor is the
  scheduler tick, 15.6 ms, which is three blocks at 48 kHz/256.

  _O_BINARY on the file sink. Without it the CRT turns every 0x0A byte of
  PCM into 0x0D 0x0A and the byte-identity proof compares a mangled file
  against a good one.

  SwitchToThread in the park spin rather than Sleep(0), which only yields
  to an equal-or-higher-priority thread on the same core.

audioif needed nothing: the pump lock's CRITICAL_SECTION branch had never
been compiled, and it compiled and held exactly as written.

Proof and how to re-run: docs/spikes/live-audio-path-windows.md in the
anchor.
The ring, the event queue, the tap and the Windows driver are now on
spike/live-audio-path; the ring's buffer positions are tracked rather than
derived from a wrapping index.
WebAssembly has no threads in the build we ship -- no -pthread, no
SharedArrayBuffer -- and emscripten's pthread_create still LINKS, so keying
off anything but __EMSCRIPTEN__ gets you a module that aborts the tab on
spawn(). The branch is taken at compile time and there is no pthread in the
binary.

audiopump_run() is now begin/blocks/end and the loop's accumulators live in
the context, so the SAME loop can be entered a block at a time and pick up
where it left off -- one digest, one block count, one wall clock across every
call. A threaded port still runs it once, to completion, and never looks at
service_mode.

spawn() on wasm adopts the tail and returns without pulling. audiopump.
service(max_blocks) runs the loop on the calling thread; audiopump.threaded()
says which shape a driver is on. park() is a store, because the loop is never
running when Python holds the only thread.

Two things the wasm branch decides differently on purpose:

- the output ring BLOCKS the pump rather than dropping. A block that will not
  fit is not pulled at all, so RING_OVF stays 0 and a ring too small to hold
  one block stalls instead of losing audio. That is the fix the desktop
  driver has been asking for, in the form a single-threaded port can have it.
- service() returns one packed small int, blocks << 2 | why, not a tuple.
  On this port service() IS the pull's entry point, so heap_lock() around it
  has to hold, and a tuple is an allocation per tick. The gate catches the
  tuple version: 200 blocks then ALLOC.
…ker are not one channel pair

i2s_start's duplex opened the RX half of the same channel pair, which is
right when one port carries both directions (the P4's ES8311) and cannot
reach a board where they are separate devices. On the LilyGO T-Embed the
MAX98357A is on port 1 with no MCLK at all and the ES7210 is on port 0 with
its own pins and a 256fs MCLK, so RX has to be a second channel on a second
peripheral.

in_port / in_bclk / in_ws / in_mclk. When in_port names a different
peripheral the RX channel is opened on it with its own GPIO config, and the
TX config stops claiming din -- otherwise the microphone is routed into the
speaker's port as well. Single-port duplex takes the same code path it
always did.

Two ports are two clock dividers off one PLL: handed the same divisor, so
nothing observable drifts, but nothing locks them the way one channel pair
locks BCLK and WS either. Measured on the board: mic -> Bitcrusher ->
speaker, 15020 blocks in 40 s at 4 x 128, clock +0.00 %, zero starved bytes,
zero input timeouts, and rx-tx read once a second stayed at 0 for the whole
run. 5.3-8.0 ms out of the speaker.
…ndows thread

The wasm branch and the windows branch each added their own arm to the same
five places in audiopump.c, and each defined a flag the other did not know
about. Every hunk keeps both: AUDIOPUMP_ESP, AUDIOPUMP_WIN and AUDIOPUMP_WASM
are now all defined in all four arms, so a guard that names one does not
silently read 0 because the branch that set it was never merged.

One resolution is not mechanical. The windows branch factored the
pthread/_beginthreadex difference out into audiopump_thread_start() and
audiopump_thread_join() under `#if !AUDIOPUMP_ESP` -- which is true on wasm,
where there is no `audiopump_thread` to declare and pthread_create aborts the
module. The guard is `!AUDIOPUMP_ESP && AUDIOPUMP_THREADED` now. spawn() and
join() already branch to audiopump_service_live before they could reach it;
this is the declaration, not the call.

Both sink behaviours stand as each port wrote them: windows opens with
_O_BINARY through audiopump_sink_open() and writes with _write(), wasm opens
with plain open() and the loop's write() branch, and the pace -- which only
unix and windows have -- still reads the wall_start alias the wasm
restructure left in run_blocks for it.
_usbif.FN_VIDEO (bit 32) has been in the C module all along and
examples/usbif_webcam.py uses it, but neither Device.FUNCTIONS nor
NativeDevice._BITS carried the name -- so device().functions("cdc", "uvc")
raised and a webcam costume had to poke _usbif directly, which is exactly
what the portable surface exists to avoid (#8).

The name is "uvc", not "video". The portable names are deliberately not the C
ones: FN_AUDIO is already "uac", and "uvc" is what the usbif.uvc module and
host_start() already take.

The costume vocabulary is now checked in all three places it is written down.
TestDeviceFunctionNames asserts the two Python tables agree with each other,
that they cover exactly the FN_* set parsed out of src/mod_usbif.c, that each
portable name carries its C bit, and that the call the issue says raised --
_names_to_mask(("cdc", "uvc")) -- returns 33 and round-trips. Changing
FN_VIDEO to 64 in the C source fails it with "32 != 64 : uvc should be
FN_VIDEO", so a C-side renumber cannot drift past this again.

Verified: 160 tests OK (156 before), 22 skipped as before, ruff clean.

Not done, deliberately: examples/usbif_webcam.py:132 still calls
_usbif.dev_functions(FN_CDC | FN_VIDEO) rather than the portable call it can
now make. That edit is a board change and this session has no board -- and
this repo already carries #11 and cameraif#4 because work was merged on a
static review with its checklist unticked. It can move whenever someone has
the hardware to watch it enumerate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CspnXSJV31fvtN4uNPFKHq
The engine went to audioif. What stays is the half that could never have gone
with it: the thread, the recursive priority-inheriting mutex, the clock, the
pacing, the two waits, and the hardware -- the I2S channel, the microphone
Input and the round-trip probe.

It binds by defining audioif_port_driver(), which overrides the weak default
in the engine at link time. The hook table sits in the SAME translation unit
as MP_REGISTER_MODULE(MP_QSTR__audioif, ...) on purpose: weak override has one
trap, and it is that a static archive can let the default satisfy the
reference first and never pull this object in, which wins silently. The module
table holds an undefined reference to _audioif_module, so this object is
always in the link, archive or not. audiopump.driver() is the run-time check
that says it worked.

Three platform branches, and a fourth that is the point: on __EMSCRIPTEN__
this file compiles to nothing. That build has no threads -- pthread_create
links and then aborts the whole module at run time, the same compiles-and-is-
silently-wrong shape AUDIOIF_DRIVER_ESP32 exists for -- and no hardware, so no
driver binds and audiopump.driver() says "none". Which is true.

The hardware names are this module's now: i2s_start, i2s_stop, i2s_dma_bytes,
i2s_rx_bytes, Input, rt_probe. lock_stats, lock_reset and fault stayed on
audiopump, because they count the engine.
What _audioif provides, call by call, including the two-port in_port form
and what it is honest about (0 bytes of drift over forty seconds on the
T-Embed, not proof of indefinite lock). What each port implements and why
WebAssembly needs no driver at all. How it is built into a Make port and a
CMake port, and why micropython.cmake defines its own esp32 macro. And what
to do to add a port -- rp2 being the obvious next -- with the binding rule
stated before the page that explains it.
    i2s = audiobusio.I2SOut(bit_clock, word_select, data)
    i2s.play(sample, loop=True)

is a CircuitPython program, and on this firmware the engine under it is the
pump: the graph is pulled on a thread of its own and the blocks go straight
into the I2S DMA. Nothing about the call says so. That is the point --
displayif already exposes mipidsi, spibus, i80bus and rgbmatrix, and this is
the audio one.

A C module, not frozen Python: it has to be there on a bare firmware, and a
frozen module is only there if the board's manifest pulls the usermod's in,
which our own builds override routinely.

Pins are displayif's convention and not a second one: an int, a machine.Pin,
or a string the port's Pin() understands.

Two paths under one play(). A signed 16-bit sample is handed to the pump as
it stands, zero copy, mono opening a mono channel. Anything else -- a WAV or
an MP3 that reads through the VFS inside get_buffer, or the unsigned array
CircuitPython's own I2SOut docstring uses -- goes through a ring the
interpreter fills and converts, which is what CircuitPython does too.

_audioif's i2s_start is now an argument parser in front of
audiopump_i2s_open(), so there is one implementation of the open and both
faces use it.
reap() services the loop before it asks whether it has finished, so
'while i2s.playing: pass' is a wait on WebAssembly rather than a hang.
It was a read of whether the pump had physically reached its park. unpark()
clears park_req and wakes the thread; ctx.parked stays set until that
thread's spin loop notices, so 'i2s.resume(); i2s.paused' was intermittently
True. It passed four runs of the gate and failed the fifth. The park is still
the mechanism underneath.
… for files

Two changes, and the second is the one that was actually starving the
docstring example.

A RawSample that is u8/u16/mono no longer goes through a ring the
interpreter fills -- it goes to the pump with a conversion scratch, and the
ring is left to the one source that genuinely needs an interpreter: a file.

And the DMA descriptor is sized in TIME. Fixed at 240 frames it is 5 ms at
48 kHz and thirty at 8 kHz, and at the low rates i2s_channel_write times out
once per descriptor and loses bytes. Measured on the P4, two seconds a row:

    8000 Hz, 240 frames   67 timeouts, 63 432 of 64 000 bytes written
    8000 Hz,  64 frames    0 timeouts, 64 008
   16000 Hz, 240 frames   67 timeouts;  80 frames: 0
   22050 Hz, 240 frames   13 timeouts; 110 frames: 0
   48000 Hz, 240 frames    0 timeouts

The control that settles it: a signed 16-bit STEREO sample at 8 kHz, which
is converted by nothing at all, starves the same 67. rate/200 keeps 48 kHz
on the 240 that is already proven. The docstring example over ten seconds is
now 0 starved and 320 000 of 320 000 bytes of DMA, against 334 and 319 680.
The driver has no Python and no test a runner could execute without building
it, so the whole of CI is: build an interpreter with it, then ask it to play
something. It needs a checkout of audioif beside this one and nothing else --
no workspace, no overlay, no board.

  busio_probe.py      audiobusio held to what CircuitPython documents it to
                      do: 32 legs over the constructor, play/stop, loop,
                      pause/resume, a Mixer, a Synthesizer, a WaveFile and
                      fifty play/stop rounds. --fault deaf makes every byte
                      count read as zero and nine legs fail, which is the
                      plant that cannot be satisfied by accident.
  sink_probe.py       the engine's own digest of what it pulled against a
                      digest of the file the sink wrote. The sink is the one
                      place a block stops being the engine's and becomes a
                      platform's, and a short write there raises nothing.
  cp_example_probe.py CircuitPython's I2SOut docstring example, character for
                      character apart from the three pin arguments.

Every planted fault runs first and must fail, the way audioif's clean-build
workflow runs its own probes. When this repository becomes part of the public
audioif, the job moves into clean-build.yml as a second cell and loses the
extra checkout.
…eams

A host stream in another C module moves packets into and out of the same
USBDevice.Ring a device-side stream uses: its size and level, an
all-or-nothing put, a get, and a staged write with a commit, so a video frame
built from many packets is published whole or not at all.
The speaker, microphone and webcam-on-the-panel examples, the S3 frame grab,
and the soft-reset, ring-size and duplex loopback checks leave usbif.auto and
the deleted UAC and UVC bindings for the packages. Each keeps what it prints
and what it measures.
…ines go

The other half of the previous commit, which removed usbif_host_uac.c,
usbif_host_uvc.c and tests/unix_c_sink.py and moved the examples; the two
build together.

usb.host.audio and usb.host.video keep their API, and the class logic moves
to Python over usb.core's control transfers: SET_INTERFACE (now sent by
set_interface_altsetting, as PyUSB does), the sample rate, a 2.0 clock's
RANGE, the camera's PROBE and COMMIT. C keeps only the moving of packets:
usbif_host_stream.c opens an isochronous endpoint of an interface claimed
through usbif_host_xfer.c and moves its packets to or from a ring in the
host task. The ring is a machine.USBDevice.Ring, the same one a device-side
stream uses, or for a microphone an audiodsp audiopump.Ring the pump plays
from (Microphone.start(ring)).

The generic pieces are host-tested headers: PCM pacing for an OUT stream
(shared/usbif_iso_pace.h, a 2.0 device's interval of frames rather than its
maximum packet) and UVC payloads into whole frames, each led in the ring by
its length, the first after a start dropped (shared/usbif_uvc_frame.h). The
queue sizing that was UVC's serves every stream (usbif_iso_queue.h).

Speaker.c_sink() gives another native module the playing stream as a
pcm_c_sink, through the same gate as Speaker.write(); the gate now takes any
ring's room and push. Camera(encoding="yuy2") now matches the YUY2 format
whatever its case.

usb.core also gains read(..., raise_on_timeout=False), as on CircuitPython:
a timeout returns 0, allocates nothing, and leaves the transfer armed for the
next read, and the endpoint's interface is looked up once rather than from
the whole descriptor each read. running() and stats() report the host.

Removed with them: the _usbif host_uac_* and host_uvc_* bindings, and the
byte ring from the firmware (it stays as the C sink tests' ring). lib/usbif's
UacHostOutput and uvc paths call those bindings and no longer work on a
board; the packages replace them.
The tethering binding's address formatter was defined on every port and used
only with a USB host, so a unix build with -Werror stopped on it as unused.
hid_host, host_enum, midi_host, usb_serial and the three drive examples move
off usbif.auto onto usb.core, usb.host.hid, usb.host.cdc, usb.host.msc and
midiports.usb_host. hid_host carries its own small key decoder and now
lights the lock LEDs. Their tests follow them, and the UAC and UVC
descriptor-parser tests move to tests/test_descriptor_parsers.py, against
the packages' own parsers.
usb.core and the class packages replaced them, and nothing calls them any
more. Removed: the usbif Python package (usbif.auto, the desktop backends,
MidiPort and the MIDI parser, uac/uvc/uac_audio, hid_keyboard) and its
tests; the C CDC, HID, MSC and MIDI host drivers with the USB-MIDI packet
codec and their bindings (host_cdc_*, host_hid_*, host_msc_*, host_midi_*),
capabilities() and the FN_* constants; the transfer-order test for those
drivers and the costume-era USBDevice check.

Kept: the host core and its event ring, the generic transfers under
usb.core, the isochronous streams under usb.host.audio and usb.host.video,
and the tethering adapter's driver. The manifest now names only the C module:
the packages are installed with mip. The leak test now holds the packages to
being the only importers of _usbif.
errno has no EPIPE on the ESP32 port, so usb.core's error mapping raised
AttributeError for every USB error that wasn't a timeout -- a stop on an
unplugged camera, say -- and usb.host.msc's handling of a stalled data
phase did the same. Both fall back to MicroPython's numbers now.
read(..., raise_on_timeout=False) gave the transfer the poll's timeout, so
ESP-IDF completed it as TIMED_OUT and the next poll raised ETIMEDOUT, which
allocates: on a board, a heap-locked polling loop failed where the unix fake
had passed. A kept transfer now waits for data however long it takes, and a
TIMED_OUT completion in a poll reads as nothing yet.
A camera whose packets are all over the host's in_limit (an ESP32-S3 takes
600 bytes; a webcam at full speed often asks 640) was refused with only the
list of its modes. The error now names the smallest packet it asked for and
the limit.
A webcam's microphone often offers only stereo (the C920e: 16, 24 or 32 kHz,
two channels), and a wake-word or speech consumer takes one. Asked for one
channel such a microphone now opens its 16-bit stereo stream, and each frame
reaches the ring as the mean of its two samples, averaged in the packet's
own buffer as it lands: no memory, no copy, nothing for Python to do.
Microphone.downmix says when it happened. host_stream takes downmix=True;
shared/usbif_downmix.h is host-tested, with a planted fault CI must catch.
…for more

640 bytes is what a computer takes beside the serial port, but an ESP32-S3
hosting the webcam takes at most 600 and refused every mode. 512 works with
any host; main(packet=640) gives a computer 640x480 at about 12 frames a
second as before. The example also imports without starting, for a program
that runs it.
…variant may carry a manifest.py

build_mp.py now uses a board variant's own manifest.py as FROZEN_MANIFEST, as it
already did for port variants (frozen_manifest(), with a test). The DEV-KIT's
includes modules/manifest.py, then freezes usb-core, the usb-host-* and
usb-device-* packages, micropython-lib's usb-device-midi/cdc/hid and midiports'
board half, only when the build carries usbif. Other targets freeze what they did.
…ames work

FatFs was built with FF_LFN_UNICODE 0, so names crossed its API in code
page 437 while MicroPython's str is UTF-8. On a card written by a PC,
os.listdir("/sd/...") raised UnicodeError, os.listdir(b"...") gave code
page bytes, and opening a name such as "Ravel - Boléro.mid" gave ENOENT;
names with characters outside code page 437 ("Dvořák") were reachable
only by their short names.

FF_LFN_UNICODE now follows MICROPY_FATFS_LFN_UNICODE and defaults to 2
(UTF-8) on every port that enables LFN. It costs about 400 bytes of code
and no RAM. docs/upstream/fatfs-utf8-filenames.md is the draft issue.
…of usbif alignment/usb e900c94)

git-subtree-dir: modules/usbhost
git-subtree-mainline: fc233ff
git-subtree-split: e900c94
…nd the packages leave for pydevices

usbif's C module, now modules/usbhost, renames everywhere: _usbhost,
mod_usbhost.c, usbhost_*.c and .h, usermod_usbhost, USBHOST_* macros. Its
one MicroPython patch joins the overlay as 0039 (the OTG controller handed
to the host). The Python packages and their tests move to pydevices, beside
midiports; the examples, the proof matrix, the stream and host checks and
the C tests stay here, and .github/workflows/usbhost.yml runs the C tests
with their planted faults, the examples' checks and the unix build.

The DEV-KIT H2 variant freezes the USB packages from modules/pydevices when
the build carries usbhost. usbif's modules.lock line and modules/usbif go.
…d of audioif main 7f1cd37)

git-subtree-dir: modules/audioif
git-subtree-mainline: c245491
git-subtree-split: 7f1cd37
…e add of displayif main 0360c53)

git-subtree-dir: modules/displayif
git-subtree-mainline: 4886faa
git-subtree-split: 0360c53
…gnment/usb work: the IMX219 and auto-exposure (git subtree add of cameraif alignment/usb c938680)

git-subtree-dir: modules/cameraif
git-subtree-mainline: 18f9212
git-subtree-split: c938680
…ock lines go

Each keeps its name, since that is what users import and what --modules
names. The build finds them as before; audioif's glue now finds audiodsp as
its sibling in modules/.
…and regenerate the series against it

From 3843e4a0e (v1.30.0-preview-133), 27 upstream commits: stm32 and mimxrt
boards, the test runner's requires: syntax, and X25519 in mbedtls (esp32's
sdkconfig.base). Every patch applied clean; 27 of the 40 (the overlay's 39
and cameraif's two) had drifted by some lines since they were written and
are regenerated with their own headers, cumulatively at the new pin. No
added or removed line changed in any of them.
… points at usbhost

Their workflows under modules/*/.github could never run. audioif.yml and
displayif.yml do what the old ones did, built with build_mp.py at the pin:
audioif's probes with their planted faults beside audiodsp, displayif's qstr
reachability, usdl2 smoke, lifecycle API and packed fill. cameraif.yml keeps
its flake8.
…one runner, each with a planted fault

enumerate, msc and hid run against what's on the host's port; hid_typed, cdc,
midi, audio and video need a peer board on that port (--peer) and say
"skipped" without one. --plant turns each check's fault on and the run ends
PLANTS CAUGHT n of n. run_matrix.py now names its board on every hold
command, so it can't find or stop another holder running beside it.
…nger fills the schedule queue

MicroPython's lib/micropython-lib points at ee4bb8f, whose usb-device-midi
0.1.0 schedules one _on_rx per OUT completion and calls schedule() before it
re-arms the endpoint. TinyUSB's task runs with the scheduler locked, so a
burst of one-event packets (about 0.3 ms apart from Windows) queues an _on_rx
per packet, fills all 8 slots, and the RuntimeError skips the re-arm: the
device never hears MIDI again. The DEV-KIT image freezes that copy, and boards
whose /lib is staged from the submodule get it too.

micropython-lib fixed it on 2026-09-16 (90a3385, 4fa59bd; 0.1.1). Both commits
are carried unchanged in patches/micropython-lib/, which apply_patches.py
puts on the submodule after every update and skips once the submodule has
them; --check tries them on the pin's submodule, and the build record names
them. On the bench, M1 over USB now passes paced and burst on an ESP32-P4 at
high speed and an ESP32-S3 at full speed; with 0.1.0 put back, the burst
fails after 15 notes.
…l-commit pin works

clone --branch takes a tag or a branch, not a commit, and UPSTREAM is now
b70ae1aca. clone_at already fetches a 40-character commit directly and a
tag as a tag.
…B packages and the pump's USB ring

pydevices 323c97f has usb-core and the usb-host and usb-device packages the
DEV-KIT freezes; audiodsp c23f67a has Ring.from_usb and the C producer's write
that usbhost's capture stream uses.
…'s read of the file

Pillow 13 honours bits= for grey and writes 1, 2 and 4-bit files, where 12
wrote 8-bit ones. A decoder scales those samples to full range, so the
in-memory image is no longer what the file holds. pngio matches Pillow's
decode of the file on 12.3 and 13.0.
@bdbarnett
bdbarnett merged commit 4fb45e8 into main Oct 19, 2026
15 checks passed
@bdbarnett
bdbarnett deleted the alignment/usb branch October 19, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants