From ea1b45bd1246657656e66cddc4e9d629d31170f3 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:04:08 -0600 Subject: [PATCH 1/2] audioif, cameraif, usbhost: type stubs and simpletests; btclassic and picotts: __revision__ audioif, cameraif and usbhost ship .pyi stubs, checked name for name against each module's C tables (audioif's are audiobusio, audioio, audioi2sin, analogbufio and _audioif; usbhost's is _usbhost, the C half the usb packages use). cameraif and usbhost get a simpletest, the shortest program that proves the module works. btclassic and picotts report which source a firmware was built from in __revision__, set from git describe at build time like the other modules. --- modules/audioif/_audioif.pyi | 346 +++++++++++++ modules/audioif/analogbufio.pyi | 44 ++ modules/audioif/audiobusio.pyi | 173 +++++++ modules/audioif/audioi2sin.pyi | 98 ++++ modules/audioif/audioio.pyi | 76 +++ modules/btclassic/btclassic.pyi | 4 + modules/btclassic/micropython.cmake | 14 + modules/btclassic/micropython.mk | 4 + modules/btclassic/src/mod_btclassic.c | 9 + modules/cameraif/README.md | 1 + modules/cameraif/ROADMAP.md | 3 +- modules/cameraif/cameraif.pyi | 460 ++++++++++++++++++ .../cameraif/examples/cameraif_simpletest.py | 40 ++ modules/picotts/micropython.cmake | 13 + modules/picotts/micropython.mk | 4 + modules/picotts/py/picotts.py | 2 + modules/picotts/src/modpicotts.c | 9 + modules/usbhost/ROADMAP.md | 2 - modules/usbhost/_usbhost.pyi | 242 +++++++++ modules/usbhost/examples/README.md | 2 +- .../usbhost/examples/usbhost_simpletest.py | 41 ++ 21 files changed, 1582 insertions(+), 5 deletions(-) create mode 100644 modules/audioif/_audioif.pyi create mode 100644 modules/audioif/analogbufio.pyi create mode 100644 modules/audioif/audiobusio.pyi create mode 100644 modules/audioif/audioi2sin.pyi create mode 100644 modules/audioif/audioio.pyi create mode 100644 modules/cameraif/cameraif.pyi create mode 100644 modules/cameraif/examples/cameraif_simpletest.py create mode 100644 modules/usbhost/_usbhost.pyi create mode 100644 modules/usbhost/examples/usbhost_simpletest.py diff --git a/modules/audioif/_audioif.pyi b/modules/audioif/_audioif.pyi new file mode 100644 index 00000000..5ae44ba1 --- /dev/null +++ b/modules/audioif/_audioif.pyi @@ -0,0 +1,346 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The audio hardware under the pump: I2S channels, microphones and the DAC. + +`_audioif` is the C half that `audiobusio`, `audioi2sin` and `audiodev` are +built on. A program uses those and doesn't import this module; this stub is for +the people who write them. Which names exist depends on the port: the ESP32 has +the I2S calls, Windows has the endpoint calls, the STM32H7 has the DAC and +capture calls, and every port has `driver()`. A port with no audio driver has no +`_audioif` at all. + +The engine's own counters (`lock_stats`, `lock_reset`, `fault`) are on +`audiopump`. +""" + +from typing import Any + +__revision__: str +"""Which source this firmware was built from, from `git describe`: a release +tag, or the nearest one and the commit. "unknown" when the build had no git. +Not on the STM32H7 build.""" + +def driver() -> str: + """Return the name of the driver this firmware bound: "esp32", "win32", + "pthread" or "stm32". `audiopump.driver()` says the same.""" + +# --- ESP32 --------------------------------------------------------------- + +def i2s_start( + port: int, + bclk: int, + ws: int, + dout: int, + rate: int, + *, + bits: int = 16, + channels: int = 2, + mclk: int = -1, + mclk_fs: int = 256, + dma_desc: int = 6, + dma_frame: int = 240, + din: int = -1, + in_port: int = -1, + in_bclk: int = -1, + in_ws: int = -1, + in_mclk: int = -1, +) -> int: + """ESP32. Open an I2S transmit channel that the pump writes every block into. + + Args: + port: The I2S peripheral. + bclk: The bit clock pin. + ws: The word select pin. + dout: The data out pin. + rate: The sample rate. + bits: Bits per sample. + channels: 1 or 2. + mclk: The master clock pin, or -1 for none. + mclk_fs: Bit clocks of master clock per frame. + dma_desc: How many DMA descriptors. + dma_frame: Frames per descriptor. + din: The data in pin; 0 or more opens the receive half of the same + channel pair, so one clock tree drives capture and playback. + in_port: With `din`, a different peripheral for the receive half, for a + board whose microphone isn't on the speaker's port. The two aren't + sample-locked. + in_bclk: The receive half's bit clock pin, with `in_port`. + in_ws: The receive half's word select pin, with `in_port`. + in_mclk: The receive half's master clock pin, with `in_port`. + + Returns: + The bytes the DMA holds when full, the block-to-wire latency floor. + + Raises: + OSError: The channel wouldn't open. + """ + +def i2s_stop() -> None: + """ESP32. Close both channels, a recording included.""" + +def i2s_dma_bytes() -> int: + """ESP32. Return the bytes the transmit DMA has clocked out, from its interrupt.""" + +def i2s_rx_bytes() -> int: + """ESP32. Return the bytes the receive DMA has clocked in, from its interrupt.""" + +def i2s_sink_bytes() -> int: + """ESP32. Return how far into the DMA's output stream the pump has + accounted for: bytes handed over plus silence already charged as starvation.""" + +def i2s_starved_bytes() -> int: + """ESP32. Return the bytes of silence the DMA sent because nothing was written.""" + +def i2s_state() -> tuple[int, int, bool, int] | None: + """ESP32. Return `(rate, channels, has_rx, holders)`, or None with no channel open. + + `holders` is 1 for the output, 2 for the recorder, 3 for both. + """ + +def rx_open( + port: int, + bclk: int, + ws: int, + dout: int, + din: int, + rate: int, + mclk: int = -1, + mclk_fs: int = 256, + ring: int = 20000, + take: int = 2, +) -> int: + """ESP32. Record from the receive half while an output plays on the same port. + + Opens the channel pair if nothing has, or joins the one + `I2SOut(..., data_in=)` opened. With `dout` -1 it opens a receive-only + channel instead, as `audioi2sin.I2SIn` does. + + Args: + port: The I2S peripheral. + bclk: The bit clock pin. + ws: The word select pin. + dout: The speaker's data pin, or -1 for receive-only. + din: The microphone's data pin. + rate: The sample rate. + mclk: The master clock pin, or -1 for none. + mclk_fs: Bit clocks of master clock per frame. + ring: Bytes of history the reader may fall behind by before audio is dropped. + take: Which slot to read: 0 left, 1 right, 2 their average (one channel + out), 3 both. + + Returns: + The size of the capture ring in bytes. + + Raises: + ValueError: A capture is already open, `take` isn't 0 to 3, `din` is + negative, or `ring` is too small. + OSError: The channel wouldn't open. + """ + +def rx_read(buf: bytearray | memoryview) -> int: + """Copy whole frames from the capture ring into `buf` without blocking. + + Returns: + The bytes copied, or 0 when nothing is waiting. + + Raises: + OSError: No capture is open. + """ + +def rx_close() -> None: + """Stop recording. The channel closes only if no output holds it.""" + +def rx_stats() -> tuple[int, int, int]: + """Return `(frames captured, frames dropped, bytes waiting)`. + + Dropped frames are a reader that fell further behind than the ring. + """ + +class Input: + """A live microphone as an audio sample the pump pulls, so an effect graph + can be built on it. The read blocks, which paces the pump. On Windows it + reads a WASAPI microphone. + + It has the audio sample properties `sample_rate`, `bits_per_sample` and + `channel_count`. + """ + + sample_rate: int + bits_per_sample: int + channel_count: int + + def __init__( + self, + *, + sample_rate: int = 48000, + channel_count: int = 2, + frames: int = 256, + timeout_ms: int = 500, + device: str | None = None, + buffer_ms: int = 100, + keep_ms: int = 10, + ring: Any = None, + ) -> None: + """Make the microphone sample. + + Args: + sample_rate: The rate to read at. + channel_count: 1 or 2. + frames: Frames per block. + timeout_ms: Milliseconds a read waits before it gives silence. + device: Windows only: the endpoint ID of the microphone; None is + the default one. + buffer_ms: Windows only: the WASAPI buffer, in milliseconds. + keep_ms: Windows only: milliseconds of audio kept ahead. + ring: Windows only: an `audiopump.Ring` that a reader thread fills + instead of the pump pulling blocks. + + Raises: + ValueError: `channel_count` or `frames` is out of range. + """ + def stats(self) -> tuple[int, ...]: + """Return what it has read and missed. + + On an ESP32, `(blocks, timeouts, short_reads, block_bytes)`. On + Windows, `(frames, short reads, glitches, dropped, timeouts, buffer + frames, frames waiting, error, period frames, latency frames)`. + """ + def deinit(self) -> None: + """Windows only. Stop capturing and let the device go.""" + +def rt_probe( + capture: bytearray | memoryview, + frames: int = 256, + lead: int = 0, + prime: int = 0, + level: int = 24000, + channels: int = 2, + timeout_ms: int = 2000, +) -> tuple[int, int, int, int, int, int]: + """ESP32. Measure the round trip from the speaker to the microphone, in frames. + + It loads a click into the transmit DMA before either channel is enabled and + captures the receive side from the same instant. Run it with no pump + spawned. + + Args: + capture: A buffer for the captured bytes. + frames: Frames in the click block. + lead: Frames of silence before the step. + prime: Blocks of silence ahead of the click, to model a full ring. + level: The step's level. + channels: 1 or 2. + timeout_ms: Milliseconds to wait for the capture. + + Returns: + `(bytes captured, frame the step leaves the DAC, frames loaded, + microseconds between enabling RX and TX, error code, frame bytes)`. + """ + +def lock_probe() -> tuple[int, int, int]: + """ESP32. Ask FreeRTOS whether it refuses a give of the pump mutex from a + task that doesn't own it. + + Returns: + `(held, freed, owner)`: another task gives a mutex the interpreter + holds, then one nobody holds, then a task gives back one it took. The + last must be 1. + """ + +# --- Windows ------------------------------------------------------------- + +def sink_stats() -> tuple[int, ...] | None: + """Windows. Return the WASAPI speaker's counters while one is open, in the + same order as `Input.stats()`, or None when the sink isn't a speaker.""" + +def endpoint(device: str | None = None, capture: bool = False) -> tuple[str, str, str]: + """Windows. Describe an audio endpoint. + + Args: + device: The endpoint ID; None is the default one. + capture: True for a microphone, False for a speaker. + + Returns: + `(id, name, container)`: the endpoint's ID, its friendly name and the ID + of the physical device it belongs to. + + Raises: + OSError: No such endpoint. + """ + +def same_clock(speaker: str | None = None, mic: str | None = None) -> bool: + """Windows. Say whether a speaker and a microphone run on one clock. + + True when both are endpoints of the same physical device. The built-in + endpoints share a container whatever their hardware, so two of them count + as two clocks. + + Raises: + OSError: No such endpoint. + """ + +# --- STM32H7 ------------------------------------------------------------- + +def dac_stats() -> tuple[int, int, int, int, int]: + """STM32H7. Return `(written, played, starved frames, service errors, ring frames)`.""" + +def render_mode(mode: str | None = None) -> str: + """STM32H7. Read or choose where the pump renders. + + Args: + mode: "irq" renders from an interrupt of the driver's own; + "interpreter" renders on the interpreter, between bytecodes. None + only reads. The choice lasts until the board resets. + + Returns: + The mode now in force. + + Raises: + ValueError: The mode is neither, or "irq" on an audiodsp that can't + render from an interrupt. + """ + +def render_stats(reset: bool = False) -> tuple[int, int, int, int, int, int]: + """STM32H7. Return `(renders, longest render in microseconds, render stack + used in bytes, render stack size, lock depth, interrupt priority)`. + + Args: + reset: Zero the first two after reading them. + """ + +def pdm_open(clock: Any, data: Any, rate: int, *, edge: int = 0) -> int: + """STM32H7. Record a PDM microphone through DFSDM1 into the capture ring, as + signed 16-bit mono. + + Args: + clock: The PDM clock pin. + data: The PDM data pin. + rate: The rate to record at. + edge: The clock edge the data is read on, 0 rising and 1 falling, + which follows how the microphone's L/R pin is tied. + + Returns: + The rate it really runs at. + """ + +def adc_open(pin: Any, rate: int, *, raw: bool = False) -> int: + """STM32H7. Record an ADC1 pin, paced by a timer, into the capture ring. + + Args: + pin: The ADC1 pin. + rate: The rate to record at. + raw: Give the ADC's unsigned values, with no high-pass or gain. + + Returns: + The rate it really runs at. + """ + +def rx_gain(db: float | None = None) -> float: + """STM32H7. Read the capture's gain in decibels, or set it from -40 to 60. + + 0 dB puts the input's full scale at int16's. It holds across recordings. + """ + +def rx_rate() -> int: + """STM32H7. Return the rate the recording runs at, or 0 with none.""" diff --git a/modules/audioif/analogbufio.pyi b/modules/audioif/analogbufio.pyi new file mode 100644 index 00000000..ccc3e17b --- /dev/null +++ b/modules/audioif/analogbufio.pyi @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Record from an analog pin: CircuitPython's `analogbufio`, on an STM32H7 board. + +It exists on the Arduino GIGA R1, where it records the headphone jack's +microphone ring. Only one of `BufferedIn` and `audiobusio.PDMIn` records at a +time. +""" + +from typing import Any + +class BufferedIn: + """An ADC1 pin sampled on each tick of a timer, a few conversions averaged + in hardware. While it records, `machine.ADC` on ADC1 has to wait.""" + + sample_rate: int + """The rate the timer really runs at, after the first `readinto()`.""" + + def __init__(self, pin: Any, *, sample_rate: int) -> None: + """Open the pin. + + Args: + pin: An ADC1 pin, such as `PA0`. + sample_rate: The rate to sample at, in hertz. + """ + def __enter__(self) -> "BufferedIn": ... + def __exit__(self, *exc: Any) -> None: ... + def deinit(self) -> None: + """Stop recording and release the pin.""" + def readinto(self, buffer: Any, *, loop: bool = False) -> int: + """Fill the whole buffer with the ADC's own 16-bit values. + + Args: + buffer: An `'H'` array for 16-bit values, or a `bytearray` or + `'B'` array for their top bytes. + loop: Raises `NotImplementedError` when True. + + Returns: + The number of samples the buffer holds. + + Raises: + ValueError: The buffer is another type. + """ diff --git a/modules/audioif/audiobusio.pyi b/modules/audioif/audiobusio.pyi new file mode 100644 index 00000000..d926fe98 --- /dev/null +++ b/modules/audioif/audiobusio.pyi @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Play audio out of an I2S bus: CircuitPython's `audiobusio`, over the audio pump. + +`I2SOut` plays an audio sample, such as a `RawSample`, a `WaveFile` or a +synth, through an I2S amplifier or codec. A program written for CircuitPython's +`I2SOut` runs unchanged apart from its pins. `PDMIn` records from a PDM +microphone and exists on the STM32H7 only. + +On a board whose audio is a DAC, such as the Arduino GIGA R1, `I2SOut` raises +`NotImplementedError`: use `audioio.AudioOut`. +""" + +from typing import Any + +class I2SOut: + """One I2S output. Only one output can exist at a time; a second raises + `RuntimeError("Peripheral in use")`. + + Calling `play` while playing stops the first sample. A sample ends by + itself and `playing` goes False. + """ + + playing: bool + """True while a sample plays, paused or not. Goes False by itself when a + sample ends.""" + paused: bool + """True after `pause()` and before `resume()`.""" + status: bytearray + """The audio pump's status words, which say why it stopped. The same object + every time.""" + + def __init__( + self, + bit_clock: Any, + word_select: Any, + data: Any, + *, + main_clock: Any = None, + left_justified: bool = False, + external_clock: bool = False, + port: int = -1, + sample_rate: int = 48000, + main_clock_fs: int = 256, + sink: str | None = None, + data_in: Any = None, + ) -> None: + """Open the bus. + + Args: + bit_clock: The bit clock pin. + word_select: The word select (left/right clock) pin. + data: The data out pin. + main_clock: The master clock pin, for a codec that needs one. + left_justified: Raises `NotImplementedError` when True; the bus is + Philips-mode. + external_clock: Raises `NotImplementedError` when True. + port: Which I2S peripheral; -1 lets the driver pick. Needed when a + board's codec and microphone are on different ones. + sample_rate: The rate the bus opens at, before the first `play()` + retunes it to the sample's. + main_clock_fs: Bit clocks of master clock per frame. + sink: Desktop only: the file the blocks are written to, or a + "wasapi" speaker on Windows. + data_in: The microphone's data pin on a board whose microphone + shares this output's clocks. The channel then opens as a + transmit and receive pair, so `_audioif.rx_open` can record + while this plays. + + Raises: + NotImplementedError: `left_justified` or `external_clock`, or the + board's audio is a DAC. + RuntimeError: An output already exists. + """ + def __enter__(self) -> "I2SOut": ... + def __exit__(self, *exc: Any) -> None: ... + def deinit(self) -> None: + """Stop playing and release the bus.""" + def play(self, sample: Any, *, loop: bool = False) -> None: + """Start playing a sample, stopping any that plays. + + Args: + sample: An audio sample: a `RawSample`, a `WaveFile`, a `Mixer` or + anything else that's an `audiosample`. + loop: Start the sample over when it ends. + + Raises: + ValueError: The sample has no frames, or its format can't be played. + """ + def retarget(self, sample: Any, *, loop: bool = False) -> None: + """Swap what's playing for another sample without a gap: no stop, and + no channel close and reopen. + + Raises: + RuntimeError: Nothing is playing. + ValueError: The new sample's format differs from what's playing. + """ + def stop(self) -> None: + """Stop playing.""" + def pause(self) -> None: + """Pause, leaving the bus clocking zeros. + + Raises: + RuntimeError: Nothing is playing. + """ + def resume(self) -> None: + """Carry on from where `pause()` stopped.""" + def starved(self) -> int: + """Return the bytes of silence the DMA played because the pump hadn't + written them in time, since `play()`. It never goes down, and is 0 on a + clean playback.""" + def sink_timeouts(self) -> int: + """Return how many blocks the sink refused because the DMA had no room: the + pump is ahead, the opposite of `starved()`.""" + +class PDMIn: + """A PDM microphone, recorded through the STM32H7's DFSDM filter. + + On every other build, making one raises `NotImplementedError`. + """ + + sample_rate: int + """The rate it really runs at: the nearest the filter can divide to.""" + + def __init__( + self, + clock_pin: Any, + data_pin: Any, + *, + sample_rate: int = 16000, + bit_depth: int = 8, + mono: bool = True, + oversample: int = 64, + startup_delay: float | None = None, + ) -> None: + """Start the microphone. + + Args: + clock_pin: The PDM clock pin. + data_pin: The PDM data pin. + sample_rate: The rate to record at. 8, 16, 24, 32 and 48 kHz are exact. + bit_depth: 8 or 16. + mono: Must be True; the board has one microphone. + oversample: Checked to be a multiple of 8 and otherwise unused: the + decimation follows the rate. + startup_delay: Seconds to wait for the microphone to settle, 0 to 1; + None waits 0.11. + + Raises: + ValueError: A setting isn't one the board supports. + RuntimeError: A recording is already open. + """ + def __enter__(self) -> "PDMIn": ... + def __exit__(self, *exc: Any) -> None: ... + def deinit(self) -> None: + """Stop the microphone.""" + def record(self, destination: Any, destination_length: int) -> int: + """Fill `destination` with samples. + + Args: + destination: A `bytearray` or `'B'` array at 8 bits, or an `'H'` + array at 16 bits. A signed `'b'` or `'h'` array takes signed + samples. + destination_length: How many samples to record. + + Returns: + The number of samples written. + + Raises: + ValueError: The length is negative or the buffer is too small or + the wrong type. + """ diff --git a/modules/audioif/audioi2sin.pyi b/modules/audioif/audioi2sin.pyi new file mode 100644 index 00000000..514bd3c3 --- /dev/null +++ b/modules/audioif/audioi2sin.pyi @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Record from an I2S microphone: CircuitPython's `audioi2sin`. + +`I2SIn` records from a MEMS microphone, or from a codec's ADC that the board +has set up first. A program written for CircuitPython's `I2SIn` runs +unchanged apart from its pins. +""" + +from typing import Any + +class I2SIn: + """One I2S microphone. Only one can exist at a time; a second raises + `RuntimeError("Peripheral in use")`. + + `record` reads from a quarter-second ring the DMA fills, so a pause between + calls (a garbage collection, a file write) loses nothing and successive + calls are continuous. + """ + + sample_rate: int + """The rate it records at.""" + bits_per_sample: int + """The width of the samples `record` writes.""" + channel_count: int + """1 for mono, 2 for stereo.""" + bit_depth: int + """The width of the samples on the wire.""" + samples_signed: bool + """True when the samples are signed.""" + overflow: bool + """True when the ring dropped samples since you last looked.""" + + def __init__( + self, + bit_clock: Any, + word_select: Any, + data: Any, + *, + main_clock: Any = None, + sample_rate: int = 16000, + bit_depth: int = 16, + output_bit_depth: int | None = None, + mono: bool = True, + left_justified: bool = False, + samples_signed: bool = True, + external_clock: bool = False, + port: int = -1, + main_clock_fs: int = 256, + source: bytes | bytearray | memoryview | None = None, + ) -> None: + """Open the microphone. + + Args: + bit_clock: The bit clock pin. + word_select: The word select pin. + data: The data in pin. + main_clock: The master clock pin, for a codec that needs one. + sample_rate: The rate to record at. + bit_depth: 8, 16, 24 or 32. A 24-bit microphone is read from the + top of its 32-bit slot. + output_bit_depth: The width of the samples written; None uses + `bit_depth`. + mono: Record the left slot only. + left_justified: Use left-justified rather than Philips framing. + samples_signed: Write signed samples. + external_clock: Raises `NotImplementedError` when True. + port: Which I2S peripheral; -1 takes the first free one. + main_clock_fs: Bit clocks of master clock per frame. + source: On a build with no I2S: a buffer of two-slot wire frames + that `record` reads in a loop. Without it a desktop records + silence, paced at the sample rate. + + Raises: + ValueError: A setting isn't one that's supported. + NotImplementedError: `external_clock`. + RuntimeError: A microphone already exists. + """ + def __enter__(self) -> "I2SIn": ... + def __exit__(self, *exc: Any) -> None: ... + def deinit(self) -> None: + """Stop recording and release the bus.""" + def record(self, destination: Any, destination_length: int) -> int: + """Fill `destination` with samples. + + Args: + destination: An array whose typecode fits the bit depth. + destination_length: How many samples to record. + + Returns: + The number of samples written. It's short when the microphone + delivered nothing for a second. + + Raises: + ValueError: The length is negative or the buffer is too small. + TypeError: The buffer's typecode doesn't fit the bit depth. + """ diff --git a/modules/audioif/audioio.pyi b/modules/audioif/audioio.pyi new file mode 100644 index 00000000..60cf1c48 --- /dev/null +++ b/modules/audioif/audioio.pyi @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Play audio through a DAC: CircuitPython's `audioio`, on an STM32H7 board. + +It exists on boards whose audio is a DAC rather than I2S, such as the Arduino +GIGA R1's headphone jack. Elsewhere there is no `audioio`; use +`audiobusio.I2SOut`. +""" + +from typing import Any + +class AudioOut: + """The DAC output. It has everything `audiobusio.I2SOut` has after its + constructor: `play`, `retarget`, `stop`, `pause`, `resume`, `deinit`, + `starved` and `sink_timeouts`, and the properties `playing`, `paused` and + `status`. + + Only one output can exist at a time; a second raises + `RuntimeError("Peripheral in use")`. + """ + + playing: bool + """True while a sample plays, paused or not.""" + paused: bool + """True after `pause()` and before `resume()`.""" + status: bytearray + """The audio pump's status words, which say why it stopped.""" + + def __init__( + self, + left_channel: Any, + *, + right_channel: Any = None, + quiescent_value: int = 0x8000, + sample_rate: int = 22050, + buffer_ms: int = 0, + ) -> None: + """Open the DAC. + + Args: + left_channel: The pin of the left DAC channel, such as `PA5`. + right_channel: The pin of the right channel, such as `PA4`; None for + one channel. + quiescent_value: The level the output rests at. Only the default, + 0x8000 (midscale), is supported. + sample_rate: The rate the DAC opens at, before the first `play()`. + buffer_ms: Milliseconds of audio the DAC's ring holds; 0 is about + 93. More rides out longer stalls and puts the sound further + behind. + + Raises: + NotImplementedError: `quiescent_value` isn't 0x8000. + ValueError: `buffer_ms` is negative. + RuntimeError: An output already exists. + """ + def __enter__(self) -> "AudioOut": ... + def __exit__(self, *exc: Any) -> None: ... + def deinit(self) -> None: + """Stop playing and release the DAC.""" + def play(self, sample: Any, *, loop: bool = False) -> None: + """Start playing a sample, stopping any that plays. Takes a `RawSample` + or a `WaveFile`.""" + def retarget(self, sample: Any, *, loop: bool = False) -> None: + """Swap what's playing for another sample without a gap.""" + def stop(self) -> None: + """Stop playing.""" + def pause(self) -> None: + """Pause playback.""" + def resume(self) -> None: + """Carry on from where `pause()` stopped.""" + def starved(self) -> int: + """Return the bytes of silence the DAC played because the pump hadn't + written them in time, since `play()`.""" + def sink_timeouts(self) -> int: + """Return how many blocks the sink refused because the ring had no room.""" diff --git a/modules/btclassic/btclassic.pyi b/modules/btclassic/btclassic.pyi index 6b73cc87..0e782ee7 100644 --- a/modules/btclassic/btclassic.pyi +++ b/modules/btclassic/btclassic.pyi @@ -46,6 +46,10 @@ them. import builtins +__revision__: str +"""Which source this firmware was built from, from `git describe`: a release +tag, or the nearest one and the commit. "unknown" when the build had no git.""" + # -- changes() bits --------------------------------------------------------- CONNECTION: int diff --git a/modules/btclassic/micropython.cmake b/modules/btclassic/micropython.cmake index 395a4bce..fe758d71 100644 --- a/modules/btclassic/micropython.cmake +++ b/modules/btclassic/micropython.cmake @@ -9,9 +9,22 @@ # through MICROPY_DEF_BOARD, which the port compiles into every file and its # qstr scan reads too (patches/ teaches the port that switch). Every other # port and chip skips the module whole. +# --- which btclassic this firmware was built from --------------------- +# Computed at build time from this repo's own git, never stored; "unknown" when +# there is no git (a tarball). Read on a target as .__revision__. +execute_process( + COMMAND git -C ${CMAKE_CURRENT_LIST_DIR} describe --always --dirty --abbrev=7 + OUTPUT_VARIABLE BTCLASSIC_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) +if(NOT BTCLASSIC_REVISION) + set(BTCLASSIC_REVISION "unknown") +endif() + if(ESP_PLATFORM AND IDF_TARGET STREQUAL "esp32") set(BTCLASSIC_IDF ${CMAKE_CURRENT_LIST_DIR}/esp-idf/btclassic) add_library(usermod_btclassic INTERFACE) + target_compile_definitions(usermod_btclassic INTERFACE BTCLASSIC_REVISION=\"${BTCLASSIC_REVISION}\") target_sources(usermod_btclassic INTERFACE ${CMAKE_CURRENT_LIST_DIR}/src/mod_btclassic.c ${CMAKE_CURRENT_LIST_DIR}/src/mod_btclassic_source.c @@ -100,6 +113,7 @@ elseif(MICROPY_BLUETOOTH_BTSTACK) # same stack, so BLE stays. include(${CMAKE_CURRENT_LIST_DIR}/btstack/btstack.cmake) add_library(usermod_btclassic INTERFACE) + target_compile_definitions(usermod_btclassic INTERFACE BTCLASSIC_REVISION=\"${BTCLASSIC_REVISION}\") target_sources(usermod_btclassic INTERFACE ${CMAKE_CURRENT_LIST_DIR}/src/mod_btclassic.c ${CMAKE_CURRENT_LIST_DIR}/src/mod_btclassic_source.c diff --git a/modules/btclassic/micropython.mk b/modules/btclassic/micropython.mk index c3fcc11b..105f4661 100644 --- a/modules/btclassic/micropython.mk +++ b/modules/btclassic/micropython.mk @@ -10,6 +10,10 @@ SRC_USERMOD_C += $(BTCLASSIC_MOD_DIR)/esp-idf/btclassic/ring.c SRC_USERMOD_C += $(BTCLASSIC_MOD_DIR)/host/btclassic_host.c CFLAGS_USERMOD += -I$(BTCLASSIC_MOD_DIR)/esp-idf/btclassic/include -I$(BTCLASSIC_MOD_DIR)/esp-idf/btclassic -DBTC_HOST=1 +# Which btclassic this firmware was built from: btclassic.__revision__. +BTCLASSIC_REVISION := $(shell git -C $(BTCLASSIC_MOD_DIR) describe --always --dirty --abbrev=7 2>/dev/null || echo unknown) +CFLAGS_USERMOD += -DBTCLASSIC_REVISION='"$(BTCLASSIC_REVISION)"' + # With audiodsp in the build, AudioSink and HandsFree are also sources the # audio pump pulls, as on a board. BTCLASSIC_AUDIODSP_DIR := $(patsubst %/src/audiocore/__init__.h,%,$(firstword $(foreach _d,$(USER_C_MODULES),\ diff --git a/modules/btclassic/src/mod_btclassic.c b/modules/btclassic/src/mod_btclassic.c index 17384f5f..8b73a10c 100644 --- a/modules/btclassic/src/mod_btclassic.c +++ b/modules/btclassic/src/mod_btclassic.c @@ -1715,8 +1715,17 @@ extern const mp_obj_type_t btc_messages_type; // the module // ========================================================================= +// btclassic.__revision__: which btclassic this firmware was built from. The +// build passes BTCLASSIC_REVISION (git describe) as a compile definition; a +// build with no git says "unknown" rather than guessing. +#ifndef BTCLASSIC_REVISION +#define BTCLASSIC_REVISION "unknown" +#endif +static const MP_DEFINE_STR_OBJ(btclassic_revision_obj, BTCLASSIC_REVISION); + static const mp_rom_map_elem_t btclassic_module_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_btclassic) }, + { MP_ROM_QSTR(MP_QSTR___revision__), MP_ROM_PTR(&btclassic_revision_obj) }, { MP_ROM_QSTR(MP_QSTR_Bluetooth), MP_ROM_PTR(&btc_bluetooth_type) }, { MP_ROM_QSTR(MP_QSTR_AudioSink), MP_ROM_PTR(&btc_sink_type) }, { MP_ROM_QSTR(MP_QSTR_HandsFree), MP_ROM_PTR(&btc_hf_type) }, diff --git a/modules/cameraif/README.md b/modules/cameraif/README.md index 1016c6e6..ccf69d2f 100644 --- a/modules/cameraif/README.md +++ b/modules/cameraif/README.md @@ -585,6 +585,7 @@ What stays this module's own: the constructor, `frame()`, `capture()`, | File | What it shows | |---|---| +| [`cameraif_simpletest.py`](examples/cameraif_simpletest.py) | The shortest proof the camera works: the sensor's name, then one frame | | [`camera_preview.py`](examples/camera_preview.py) | The camera on the board's own panel. No PC, no network, no USB | | [`camera_espcamera.py`](examples/camera_espcamera.py) | espcamera's API: `take()`, `reconfigure()`, `vflip` and `hmirror`, a JPEG | | [`camera_snapshot.py`](examples/camera_snapshot.py) | A still to the filesystem as hardware-encoded JPEG | diff --git a/modules/cameraif/ROADMAP.md b/modules/cameraif/ROADMAP.md index 22667e75..5920435f 100644 --- a/modules/cameraif/ROADMAP.md +++ b/modules/cameraif/ROADMAP.md @@ -4,8 +4,7 @@ `FrameSize` the sensor can stream natively (the OV5647's `FHD`) costs no scaling and keeps the whole field of view. - `GRAYSCALE` straight from the ISP's luma, instead of a pass on the PPA. -- Tests that run in CI without a camera, beside the lint it runs today, and - the simpletest renamed `cameraif_simpletest.py` to match every other module. +- Tests that run in CI without a camera, beside the lint it runs today. Bugs, and things you need that don't work yet, go to [issues](https://github.com/PyDevices/micropython-pydevices/issues). diff --git a/modules/cameraif/cameraif.pyi b/modules/cameraif/cameraif.pyi new file mode 100644 index 00000000..afb019da --- /dev/null +++ b/modules/cameraif/cameraif.pyi @@ -0,0 +1,460 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Use a MIPI-CSI camera: live frames, stills, JPEG, and the picture controls. + +`Camera` finds the sensor on the bus you name, starts it streaming and hands +you frames as memory views, bytes or scaled pictures in a display's buffer. +It runs on the ESP32-P4. On every other chip `available()` is False and +making a `Camera` raises `OSError`. + +On an ESP32-P4 the module is also `espcamera`, with that module's `take()`, +`reconfigure()`, enums and properties, so CircuitPython code written for a +camera runs with only the constructor changed. + +`capture_jpeg()` needs `jpegio` in the firmware, and `capture_scaled()`, +`capture_yuy2()`, `take()` at another size and GRAYSCALE need `ppa`. +""" + +from typing import Any + +__revision__: str +"""Which source this firmware was built from, from `git describe`: a release +tag, or the nearest one and the commit. "unknown" when the build had no git.""" + +def available() -> bool: + """Say whether this firmware can open a camera at all. + + True when the build includes at least one sensor driver, False on every + other chip and on a P4 build with none. It doesn't say whether a sensor is + plugged in: making a `Camera` does that, with a message. + """ + +class PixelFormat: + """What `Camera.take()` returns. Small integers, as in `espcamera`.""" + + RGB565: int + """Two bytes a pixel.""" + GRAYSCALE: int + """One byte of brightness a pixel.""" + JPEG: int + """A JPEG-encoded picture.""" + +class FrameSize: + """The size `Camera.take()` returns. Small integers, in order of size.""" + + R96X96: int + QQVGA: int + QCIF: int + HQVGA: int + R240X240: int + QVGA: int + CIF: int + HVGA: int + VGA: int + SVGA: int + XGA: int + HD: int + SXGA: int + UXGA: int + FHD: int + P_HD: int + P_3MP: int + QXGA: int + QHD: int + WQXGA: int + P_FHD: int + QSXGA: int + +class GrabMode: + """How `Camera.take()` picks a frame. Here only for code written for + `espcamera`: a camera always gives the newest frame.""" + + WHEN_EMPTY: int + LATEST: int + +class GainCeiling: + """Here so code written for `espcamera` still imports. No sensor driver + has a gain ceiling, so setting `Camera.gain_ceiling` always raises.""" + + GAIN_2X: int + GAIN_4X: int + GAIN_8X: int + GAIN_16X: int + GAIN_32X: int + GAIN_64X: int + GAIN_128X: int + +class Camera: + """One camera on the CSI connector, streaming from the moment it exists. + + The chip has one CSI controller, so only one `Camera` can exist at a time; + making a second raises. A `Camera` is a context manager that calls + `deinit` on the way out. + + Besides the methods, a `Camera` has the properties `espcamera` has. The + sensor's own controls (`vflip`, `hmirror`, `aec_value`, `agc_gain`, + `brightness`, `contrast`, `saturation`, `sharpness`, `denoise`, + `whitebal`, `gain_ctrl`, `exposure_ctrl`, `ae_level`, `wb_mode`, + `special_effect` and `lenc`) read and write the sensor's own units, and + raise `AttributeError` on a sensor whose driver lacks one; `controls()` + says which it has. + """ + + pixel_format: int | None + """What `take()` returns, a `PixelFormat`, or None for the sensor's own + frame. Read-only; `reconfigure()` changes it.""" + frame_size: int + """The size `take()` returns, a `FrameSize`. Read-only; `reconfigure()` + changes it.""" + width: int + """Width of what `take()` returns.""" + height: int + """Height of what `take()` returns.""" + quality: int + """`take()`'s JPEG quality, 1 to 100. Raises `ValueError` outside that.""" + colorbar: bool + """The sensor's test pattern. Reads back what was last set.""" + sensor_name: str + """The sensor's name.""" + address: int + """The sensor's address on the SCCB bus.""" + supports_jpeg: bool + """True when `jpegio` is in the firmware and `take()` has a pixel format.""" + max_frame_size: int | None + """The largest `FrameSize` the sensor's mode holds without scaling up.""" + frame_available: bool + """True when a frame is waiting.""" + grab_mode: int + """Always `GrabMode.LATEST`.""" + framebuffer_count: int + """Always 2.""" + + vflip: bool | None + """Vertical flip, in the sensor. Reads back what was last set.""" + hmirror: bool | None + """Horizontal mirror, in the sensor. Reads back what was last set.""" + aec_value: int | None + """Exposure, in the sensor's units.""" + agc_gain: int | None + """Gain, in the sensor's units.""" + brightness: int | None + contrast: int | None + saturation: int | None + sharpness: int | None + denoise: int | None + whitebal: bool | None + """Automatic white balance. On a sensor with none of its own, the ISP's.""" + gain_ctrl: bool | None + exposure_ctrl: bool | None + ae_level: int | None + wb_mode: int | None + special_effect: int | None + lenc: bool | None + + gain_ceiling: int + """Always raises `AttributeError`: no sensor driver has one.""" + aec2: int + """Always raises `AttributeError`.""" + awb_gain: int + """Always raises `AttributeError`.""" + dcw: int + """Always raises `AttributeError`.""" + bpc: int + """Always raises `AttributeError`.""" + wpc: int + """Always raises `AttributeError`.""" + raw_gma: int + """Always raises `AttributeError`.""" + + def __init__( + self, + sda: int, + scl: int, + *, + i2c: Any = 0, + ldo_chan: int = 3, + ldo_mv: int = 2500, + reset: int = -1, + pwdn: int = -1, + xclk: int = -1, + xclk_hz: int = 24000000, + format: str | int | tuple[int, int] | None = None, + pixel_format: int | str | None = None, + raw: bool = False, + lane_mbps: int = 0, + exposure: int | None = None, + gain: int | None = None, + flip: bool = False, + mirror: bool = False, + test_pattern: bool = False, + bayer: str | None = None, + awb: bool = True, + auto_exposure: bool | None = None, + black_level: int | None = None, + sensor: dict[str, Any] | None = None, + frame_size: int | None = None, + jpeg_quality: int = 80, + tuning: dict[str, Any] | None = None, + ) -> None: + """Find the sensor and start it streaming. + + Args: + sda: The I2C data pin the sensor answers on. + scl: The I2C clock pin. + i2c: The I2C port, which must be the one that already owns those + pins when the camera shares a bus with a touch controller or + codec. On CircuitPython a `busio.I2C`, such as `board.I2C()`. + ldo_chan: The LDO channel that powers the MIPI D-PHY. + ldo_mv: That LDO's voltage in millivolts. + reset: The sensor's reset pin, or -1 for none. + pwdn: The sensor's power-down pin, or -1 for none. + xclk: The pin to drive the sensor's clock on, or -1 when the + camera module has its own oscillator. + xclk_hz: The frequency of that clock. + format: The sensor mode, by name, by index, or by size as + `(width, height)`; None takes the driver's default. + pixel_format: What `take()` returns, a `PixelFormat`; None + returns the sensor's own frame. "rgb888" asks for 24-bit + frames from `frame()` and `capture()`. + raw: Skip the ISP and return the sensor's raw bytes. + lane_mbps: Override the driver's MIPI lane rate; 0 trusts it. + exposure: Starting exposure, 0 to 100 percent of the sensor's range. + gain: Starting gain, 0 to 100 percent. + flip: Start flipped vertically. + mirror: Start mirrored horizontally. + test_pattern: Start with the sensor's test pattern. + bayer: The ISP's colour order, "BGGR", "GBRG", "GRBG" or "RGGB"; + None works it out from the sensor and the orientation. + awb: Automatic white balance. + auto_exposure: Steer exposure and gain toward a brightness + target. On unless False. + black_level: What the sensor reads with no light, in 8-bit steps. + sensor: A sensor described in Python, used instead of probing. + frame_size: The size `take()` returns, a `FrameSize`; None is the + sensor's own. + jpeg_quality: `take()`'s JPEG quality, 1 to 100. + tuning: Colour tuning over the sensor's own, with the keys + `tuning()` returns. + + Raises: + OSError: No sensor answered, a camera already exists, or this + chip has no MIPI-CSI port. + ValueError: An argument is out of range or a sensor description + is malformed. + """ + def __enter__(self) -> "Camera": ... + def __exit__(self, *exc: Any) -> None: ... + + def frame(self, timeout: int = 1000) -> memoryview | None: + """Return the newest frame where it landed, with no copy. + + The view is borrowed: it's valid until your next `frame()` or + `capture()`, so copy what you need to keep. + + Args: + timeout: Milliseconds to wait for a frame. + + Returns: + The frame, or None when none arrived in time. + """ + def capture(self, buf: bytearray | memoryview | None = None, timeout: int = 1000) -> int | bytes: + """Copy a frame out. + + Args: + buf: A buffer at least as big as a frame (`size()` says), which + is filled; None makes a new `bytes`. + timeout: Milliseconds to wait for a frame. + + Returns: + With a buffer, the number of bytes copied, or 0 when no frame + arrived. Without, the frame as `bytes`. + + Raises: + ValueError: The buffer is too small. + """ + def capture_scaled( + self, + dst: bytearray | memoryview, + pic_w: int, + pic_h: int, + *, + x: int = 0, + y: int = 0, + w: int = 0, + h: int = 0, + rotate: int = 0, + mirror: bool = False, + src_rect: tuple[int, int, int, int] | None = None, + swap: bool = False, + timeout: int = 1000, + ) -> tuple[int, int] | None: + """Scale the next frame into a block of a picture, such as a panel's buffer. + + Needs `ppa` in the firmware. The destination is the whole picture plus + the block to write inside it, so a preview can hand over its scanout + buffer. It should start on a cache line, or the scale runs in software. + + Args: + dst: The destination buffer, `pic_w` x `pic_h` RGB565. + pic_w: The destination picture's width. + pic_h: The destination picture's height. + x: Left edge of the block to write. + y: Top edge of the block to write. + w: Width of the block; 0 fills to the picture's edge. + h: Height of the block; 0 fills to the picture's edge. + rotate: 0, 90, 180 or 270 degrees. + mirror: Flip horizontally. + src_rect: `(x, y, w, h)` of the part of the frame to use, for + digital zoom. + swap: Swap the two bytes of each pixel, for a panel that takes + big-endian RGB565. + timeout: Milliseconds to wait for a frame. + + Returns: + The `(width, height)` written, or None when no frame arrived. + """ + def capture_yuy2(self, buf: bytearray | memoryview, width: int, height: int, timeout: int = 1000) -> int: + """Convert a frame to YUY2 at `width` x `height` into `buf`, as a USB webcam sends. + + Needs `ppa` in the firmware. + + Returns: + The number of bytes written, or 0 when no frame arrived. + """ + def capture_jpeg(self, quality: int = 80, timeout: int = 1000) -> bytes | None: + """Encode a frame as a JPEG with the chip's hardware encoder. + + Needs `jpegio` in the firmware. + + Args: + quality: 1 to 100; higher is sharper and larger. + timeout: Milliseconds to wait for a frame. + + Returns: + The JPEG, or None when no frame arrived. + + Raises: + OSError: `jpegio` isn't in the firmware. + """ + def take(self, timeout: float | None = 0.25) -> memoryview | Any | None: + """Return the newest frame as `pixel_format` and `frame_size` say. + + The `espcamera` way. The frame is borrowed: it's valid until the next + `take()`. In RGB565 and GRAYSCALE it's the pixels (a read-only + `displayio.Bitmap` on CircuitPython, a `memoryview` on MicroPython); + in JPEG it's a `memoryview` of the encoded bytes. + + Args: + timeout: Seconds to wait. + + Returns: + The frame, or None when none arrived in time. + """ + def reconfigure( + self, + *, + frame_size: int | None = None, + pixel_format: int | None = None, + grab_mode: int | None = None, + framebuffer_count: int | None = None, + ) -> None: + """Change what `take()` returns on the running camera. + + `grab_mode` and `framebuffer_count` are accepted and ignored. + + Raises: + ValueError: The camera was opened without a pixel format, or the + pair isn't one `take()` can make. + """ + + def size(self) -> tuple[int, int, int]: + """Return `(width, height, bytes_per_frame)` of the sensor's frame.""" + def sensor(self) -> tuple[str, int]: + """Return `(name, product_id)` of the sensor.""" + def formats(self) -> list[tuple[str, int, int]]: + """Return `(name, width, height)` for each mode the driver offers.""" + def controls(self) -> dict[str, tuple[int, int, int]]: + """Return `{name: (minimum, maximum, step)}` for what this sensor's driver implements.""" + def exposure(self, pct: int | None = None, *, raw: bool = False) -> int | tuple[int, int, int, int] | None: + """Read or set exposure as a percent of the sensor's range. + + Setting it turns `auto_exposure` off. + + Args: + pct: The new percent, or None to read. + raw: Use the sensor's own units. Read, this returns + `(current, minimum, maximum, step)`. + + Returns: + The value, or None when the driver can't say. + """ + def gain(self, pct: int | None = None, *, raw: bool = False) -> int | tuple[int, int, int, int] | None: + """Read or set gain as a percent of the sensor's range. Takes what `exposure` takes.""" + def flip(self, on: bool | None = None) -> bool | None: + """Read or set the sensor's vertical flip.""" + def mirror(self, on: bool | None = None) -> bool | None: + """Read or set the sensor's horizontal mirror.""" + def test_pattern(self, on: bool | None = None) -> bool: + """Read or set the sensor's test pattern. Reads back what was last set.""" + def bayer(self) -> str: + """Return the ISP's colour order the camera opened with.""" + def awb(self, on: bool | None = None) -> bool: + """Read or set automatic white balance.""" + def wb_gains(self, red: float | None = None, blue: float | None = None) -> tuple[float, float]: + """Read or set the white balance's red and blue gains (green is 1). + + Setting either turns `awb` off. + """ + def auto_exposure(self, on: bool | None = None, *, target: int | None = None) -> tuple[bool, int, int]: + """Steer exposure and gain toward a brightness target every frame. + + Args: + on: Turn it on or off; None leaves it. + target: The brightness to aim for, 0 to 255 after gamma. + + Returns: + `(on, target, brightness)`. + """ + def black_level(self, level: int | None = None) -> int: + """Read or set what the sensor reads with no light, taken off before gamma.""" + def ccm(self, matrix: tuple[float, ...] | list[float] | None = None) -> tuple[float, ...]: + """Read or set the colour matrix, nine numbers row by row.""" + def gamma(self, exponent: float | None = None) -> float: + """Read or set the tone curve's exponent; below 1 lifts the mid-tones.""" + def color( + self, + contrast: int | None = None, + saturation: int | None = None, + brightness: int | None = None, + hue: int | None = None, + ) -> dict[str, int]: + """Set any of the ISP's colour settings and return all four. + + Args: + contrast: 0 to 255; 128 leaves it as is. + saturation: 0 to 255; 0 is grey, 128 leaves it as is. + brightness: -128 to 127, added. + hue: 0 to 360 degrees round the colour wheel. + + Raises: + ValueError: A value is out of range; nothing changes. + """ + def tuning(self, **keys: Any) -> dict[str, Any]: + """Return the sensor's colour tuning, changing any key you pass. + + The keys are `gamma`, `black_level`, `ccm`, `ccm_table`, `awb_table` + and `color`. What it returns can be passed back as `tuning(**saved)`. + """ + def color_temp(self) -> int | None: + """Return the light's colour temperature in kelvin, or None without a matrix table.""" + def reg(self, addr: int, value: int | None = None) -> int | None: + """Read one sensor register, or write it when you give a value.""" + def stats(self) -> tuple[int, int, int, int, int]: + """Return `(frames, dropped, isr_new, isr_done, last_received)`. + + When there is no picture and `isr_done` isn't climbing, nothing is + arriving: look at power, clock, lanes or the sensor's stream. + """ + def deinit(self) -> None: + """Stop the camera and free the CSI controller. Safe to call twice.""" diff --git a/modules/cameraif/examples/cameraif_simpletest.py b/modules/cameraif/examples/cameraif_simpletest.py new file mode 100644 index 00000000..8acbd015 --- /dev/null +++ b/modules/cameraif/examples/cameraif_simpletest.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""Open the camera, grab one frame and print what came back. + +The shortest program that proves cameraif works: it names the sensor the +camera found, then waits for one frame from the sensor and prints its size. +If it prints a frame size, the sensor, the MIPI lanes and the capture path +are all working. + +Needs: an ESP32-P4 board with a MIPI-CSI camera, set up in board_config, +and firmware built with cameraif. + +Run it: + + mpremote run cameraif_simpletest.py + +Tested on: Espressif ESP32-P4X-EYE with an OV2710 camera, MicroPython +v1.30.0-preview-161-ga8a0659f9. +""" + +import board_config + +camera = board_config.camera +try: + # Ask the camera which sensor answered and how big its frames are. + name, product_id = camera.sensor() + width, height, frame_bytes = camera.size() + print("sensor:", name, product_id) + print("frame: %dx%d, %d bytes" % (width, height, frame_bytes)) + + # Wait up to a second for one frame; the view is valid until the next call. + frame = camera.frame(1000) + if frame is None: + print("no frame arrived") + else: + print("got a frame of %d bytes" % len(frame)) +finally: + # Release the camera for the next program. + camera.deinit() diff --git a/modules/picotts/micropython.cmake b/modules/picotts/micropython.cmake index eb97ad71..691a0f64 100644 --- a/modules/picotts/micropython.cmake +++ b/modules/picotts/micropython.cmake @@ -37,4 +37,17 @@ target_sources(usermod_picotts INTERFACE ) target_include_directories(usermod_picotts INTERFACE ${PICOTTS_SVOX}) target_compile_definitions(usermod_picotts INTERFACE PICO_PLATFORM=PICO_CIRCUITPY) + +# --- which picotts this firmware was built from --------------------------- +# Computed at build time from this repo's own git, never stored; "unknown" when +# there is no git (a tarball). Read on a target as picotts.__revision__. +execute_process( + COMMAND git -C ${PICOTTS_DIR} describe --always --dirty --abbrev=7 + OUTPUT_VARIABLE PICOTTS_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) +if(NOT PICOTTS_REVISION) + set(PICOTTS_REVISION "unknown") +endif() +target_compile_definitions(usermod_picotts INTERFACE PICOTTS_REVISION=\"${PICOTTS_REVISION}\") target_link_libraries(usermod INTERFACE usermod_picotts) diff --git a/modules/picotts/micropython.mk b/modules/picotts/micropython.mk index 3c8ccf60..e5d05950 100644 --- a/modules/picotts/micropython.mk +++ b/modules/picotts/micropython.mk @@ -17,6 +17,10 @@ CFLAGS_USERMOD += -I$(PICOTTS_SVOX) -DPICO_PLATFORM=PICO_CIRCUITPY SRC_USERMOD_C += $(PICOTTS_MOD_DIR)/src/modpicotts.c +# Which picotts this firmware was built from: picotts.__revision__. +PICOTTS_REVISION := $(shell git -C $(PICOTTS_MOD_DIR) describe --always --dirty --abbrev=7 2>/dev/null || echo unknown) +CFLAGS_USERMOD += -DPICOTTS_REVISION='"$(PICOTTS_REVISION)"' + PICOTTS_SVOX_SRC := $(wildcard $(PICOTTS_SVOX)/*.c) SRC_USERMOD_LIB_C += $(PICOTTS_SVOX_SRC) diff --git a/modules/picotts/py/picotts.py b/modules/picotts/py/picotts.py index 7ec7abb2..fce4933a 100644 --- a/modules/picotts/py/picotts.py +++ b/modules/picotts/py/picotts.py @@ -26,6 +26,8 @@ import _picotts as _native +__revision__ = _native.__revision__ # which source the firmware was built from + _SAMPLE_RATE = 16000 _NATURAL_WPM = 206 # Pico's speaking rate at , measured # Pico's markup accepts 20 to 500 % speed and 50 to 200 % pitch (svox/picotok.c). diff --git a/modules/picotts/src/modpicotts.c b/modules/picotts/src/modpicotts.c index 81de3387..7b98c264 100644 --- a/modules/picotts/src/modpicotts.c +++ b/modules/picotts/src/modpicotts.c @@ -293,8 +293,17 @@ static MP_DEFINE_CONST_OBJ_TYPE( locals_dict, &picotts_engine_locals_dict ); +// _picotts.__revision__: which picotts this firmware was built from. The build +// passes PICOTTS_REVISION (git describe) as a compile definition; a build with +// no git says "unknown" rather than guessing. picotts.py passes it on. +#ifndef PICOTTS_REVISION +#define PICOTTS_REVISION "unknown" +#endif +static const MP_DEFINE_STR_OBJ(picotts_revision_obj, PICOTTS_REVISION); + static const mp_rom_map_elem_t picotts_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR__picotts) }, + { MP_ROM_QSTR(MP_QSTR___revision__), MP_ROM_PTR(&picotts_revision_obj) }, { MP_ROM_QSTR(MP_QSTR_Engine), MP_ROM_PTR(&picotts_engine_type) }, { MP_ROM_QSTR(MP_QSTR_psram), MP_ROM_PTR(&picotts_psram_obj) }, { MP_ROM_QSTR(MP_QSTR_samples), MP_ROM_PTR(&picotts_samples_obj) }, diff --git a/modules/usbhost/ROADMAP.md b/modules/usbhost/ROADMAP.md index 189ec6e4..13edeb28 100644 --- a/modules/usbhost/ROADMAP.md +++ b/modules/usbhost/ROADMAP.md @@ -14,8 +14,6 @@ packages themselves are in pydevices' roadmap. - Fewer lost packets on the USB sound card. At 48 kHz on a high-speed port about one packet in 500 goes missing in bursts, where a 24 kHz wire loses none; the cause isn't found yet. -- A `usbhost_simpletest.py`: the shortest program that lists what's plugged - in, as every other module's simpletest is. Bugs, and things you need that don't work yet, go to [issues](https://github.com/PyDevices/micropython-pydevices/issues). diff --git a/modules/usbhost/_usbhost.pyi b/modules/usbhost/_usbhost.pyi new file mode 100644 index 00000000..732ef258 --- /dev/null +++ b/modules/usbhost/_usbhost.pyi @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""The USB host port, its transfers and its isochronous streams. + +`_usbhost` is the C half that `usb.core` and the `usb.host.*` packages are +built on. A program uses those packages and doesn't import this module; this +stub is for the people who write them. + +Devices are named by the `dev_id` that `host_devices()` and `host_drain()` +report. Transfers block until they finish or time out, and raise `OSError` +with the errno: `ETIMEDOUT`, `EPIPE` for a stall, `ENODEV` once the device is +gone, `EACCES` for an endpoint whose interface isn't claimed. On a firmware +built without a USB host, calls that need one raise `OSError(EOPNOTSUPP)`. +""" + +from typing import Any, Iterable + +__revision__: str +"""Which source this firmware was built from, from `git describe`: a release +tag, or the nearest one and the commit. "unknown" when the build had no git.""" + +# One attached device, as host_devices() and host_drain() report it: +# (dev_id, vid, pid, product, serial, classes, speed). `product` and `serial` +# are None here, filled in by the class drivers. `classes` is a set of +# "hid", "msc", "cdc", "midi", "uac", "uvc" and "other"; `speed` is "low", +# "full" or "high", or None. +_Device = tuple[int, int, int, str | None, str | None, set[str], str | None] + +def host_start(classes: Iterable[str], *, full_speed: bool = False) -> set[str]: + """Start the USB host, or leave a running one running. + + Args: + classes: The device classes to start: any of "hid", "msc", "cdc", + "midi", "uac", "uvc" and "other". Names this firmware can't drive + are left out, and so are unknown ones. Takes effect when the host + starts, not on a running host. + full_speed: Hold the root port to full speed, so full- and low-speed + devices work behind a hub on the ESP32-P4's high-speed host. + + Returns: + The set of classes actually started. + + Raises: + OSError: The host wouldn't start, or is wedged by a device that + couldn't be released and needs a board reset. + """ + +def host_stop() -> None: + """Stop the host, close the classes and streams left open, and free the port.""" + +def host_devices() -> tuple[_Device, ...]: + """Return the devices attached now, up to eight.""" + +def host_drain(limit: int) -> tuple[list[tuple[int, _Device]], bool]: + """Take up to `limit` attach and detach events off the queue. + + Args: + limit: The most events to take; the rest stay queued. + + Returns: + `(events, lost)`. Each event is `(kind, device)` with kind 0 for an + attach and 1 for a detach. `lost` is True when events were dropped + since the last call. + + Raises: + ValueError: `limit` is negative. + """ + +def host_stats() -> tuple[bool, int, int, int, int, int] | None: + """Return `(running, attaches, detaches, errors, lib_devices, lib_clients)`, + or None on a firmware without a host.""" + +def host_port_cycle() -> int | None: + """Power-cycle the root port to force a fresh connect, a software replug. + + Returns: + The driver's result, or None on a firmware without a host. + """ + +def host_intr_dump() -> None: + """Print the host controller's interrupt counters, for debugging.""" + +def host_desc(dev_id: int) -> bytes: + """Return a device's whole active configuration descriptor. + + It holds the interfaces, alternate settings, endpoints and class-specific + descriptors, for the caller to parse. + + Raises: + OSError: No such device, or it has no active configuration. + """ + +def host_claim(dev_id: int, interface: int, alternate: int = 0) -> bool: + """Claim an interface, at an alternate setting. + + Returns: + True for a fresh claim, whose pipes start at DATA0; False when it was + already claimed. + """ + +def host_release(dev_id: int, interface: int) -> None: + """Release a claimed interface.""" + +def host_ctrl(dev_id: int, setup: bytes | bytearray | memoryview, buf: Any, timeout_ms: int) -> int: + """Run a control transfer. + + Args: + dev_id: The device. + setup: The 8-byte SETUP packet. + buf: For an IN request, a writable buffer for the data; for an OUT + request, the data to send; None for a request with no data stage. + It must hold at least the packet's `wLength` bytes. + timeout_ms: Milliseconds to wait. + + Returns: + The number of bytes moved. + + Raises: + ValueError: The setup isn't 8 bytes or the buffer is too short. + """ + +def host_xfer(dev_id: int, endpoint: int, buf: Any, timeout_ms: int, raise_on_timeout: bool = True) -> int: + """Run a bulk or interrupt transfer. + + An IN endpoint (address bit 7 set) reads into `buf`; an OUT endpoint + sends it. + + Args: + dev_id: The device. + endpoint: The endpoint address. + buf: A writable buffer for IN, or the data for OUT. + timeout_ms: Milliseconds to wait; 0 waits for good. + raise_on_timeout: When False, an IN read that times out returns 0 and + leaves its transfer armed for the next read to collect. + + Returns: + The number of bytes moved. + """ + +def host_poll(dev_id: int, endpoint: int, buf: bytearray | memoryview) -> int: + """Collect an IN read without waiting. + + The same kept transfer as `host_xfer` with `raise_on_timeout` False, so + the two can share an endpoint. + + Returns: + The bytes the transfer brought if it has finished, otherwise 0 at once. + """ + +def host_stream( + dev: int, + ep: int, + ring: Any, + packet: int = 0, + *, + rate: int = 0, + frame: int = 0, + video: bool = False, + max_frame: int = 0, + downmix: bool = False, +) -> None: + """Move an isochronous endpoint's packets to or from a ring, in C. + + The endpoint's interface must be claimed at its streaming alternate + setting. Pass `None` as `ring` to stop the stream. + + Args: + dev: The device. + ep: The endpoint address; an IN endpoint fills the ring, an OUT + endpoint drains it. + ring: A `machine.USBDevice.Ring`, or for a capture stream an + `audiopump.Ring`. None stops the stream. + packet: The packet size in bytes. + rate: The sample rate, to pace a PCM OUT stream. + frame: The bytes in one frame of PCM, to pace a PCM OUT stream. + video: Assemble UVC frames, each led in the ring by its 4-byte length. + max_frame: The largest video frame in bytes. + downmix: Average a stereo 16-bit IN stream to mono as its packets land. + + Raises: + TypeError: `ring` is neither kind. + ValueError: An `audiopump.Ring` was given for an OUT or video stream. + OSError: The interface isn't claimed at its streaming setting, no + stream is free, or this firmware's host has no isochronous streams. + """ + +def host_stream_stats(dev_id: int, ep: int) -> tuple[int, int, int, int, int, int, int, int, int] | None: + """Return `(packets, bytes, dropped, starved, errors, empty, frames, torn, big)` + for a stream, or None when the endpoint has none.""" + +def host_stream_write(dev_id: int, ep: int, buf: bytes | bytearray | memoryview) -> int: + """Queue PCM on an OUT stream. + + Returns: + The bytes taken, in whole frames; 0 when the ring is full. + """ + +def host_stream_space(dev_id: int, ep: int) -> int: + """Return the bytes a `host_stream_write` can take now, in whole frames.""" + +def host_stream_c_sink(dev_id: int, ep: int, channels: int, bits: int) -> bytes: + """Return the OUT stream's C sink structure by value, for another native + module to copy and write to from its own task. + + It goes dead once the stream stops; a new stream issues a new one. + """ + +def host_net_start( + dev_id: int, + interface: int, + ep_in: int, + ep_out: int, + max_packet: int, + mac: bytes | bytearray | memoryview, + rndis: bool, +) -> None: + """Bring up a USB network adapter as a network interface. + + Args: + dev_id: The device. + interface: The data interface. + ep_in: The bulk IN endpoint address. + ep_out: The bulk OUT endpoint address. + max_packet: The endpoints' maximum packet size. + mac: The adapter's 6-byte MAC address. + rndis: True for an RNDIS adapter, which frames its packets. + + Raises: + ValueError: `mac` isn't 6 bytes. + OSError: This firmware has no network. + """ + +def host_net_stop() -> None: + """Take the USB network interface down.""" + +def host_net_info() -> tuple[bool, tuple[str, str, str, str], tuple[int, int, int, int, int]] | None: + """Return `(up, (ip, netmask, gateway, dns), (rx, tx, rx_dropped, tx_dropped, rx_errors))`, + or None when no interface is up.""" + +def host_net_default() -> None: + """Make the USB network interface the default route.""" diff --git a/modules/usbhost/examples/README.md b/modules/usbhost/examples/README.md index 3aadbb83..2025e526 100644 --- a/modules/usbhost/examples/README.md +++ b/modules/usbhost/examples/README.md @@ -15,7 +15,7 @@ pairing is the same scripts on two boards, not a special `pair_*` file. | **MSC** | [`sd_drive.py`](sd_drive.py), [`ram_drive.py`](ram_drive.py) | [`usb_drive_mount.py`](usb_drive_mount.py), [`usb_drive_read.py`](usb_drive_read.py), [`usb_drive_log.py`](usb_drive_log.py) | | **CDC** | MicroPython's own console | [`usb_serial.py`](usb_serial.py) | | **NET** | — | [`phone_tether.py`](phone_tether.py) (the internet through an Android phone's USB tethering) | -| **enum** | — | [`host_enum.py`](host_enum.py) (lists what is plugged in, watches attach and detach) | +| **enum** | — | [`usbhost_simpletest.py`](usbhost_simpletest.py) (the shortest list of what is plugged in), [`host_enum.py`](host_enum.py) (names each device, watches attach and detach) | ## Board-to-board pairings diff --git a/modules/usbhost/examples/usbhost_simpletest.py b/modules/usbhost/examples/usbhost_simpletest.py new file mode 100644 index 00000000..2fbc3b59 --- /dev/null +++ b/modules/usbhost/examples/usbhost_simpletest.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2026 Brad Barnett +# +# SPDX-License-Identifier: MIT +"""List the USB devices plugged into the board's host port. + +The shortest program that proves usbhost works: it starts the host, waits +a moment for devices to enumerate, and prints the vendor and product ID of +each one. `host_enum.py` shows how to read their names and watch them come +and go. + +Needs: a board with USB host support (firmware built with usbhost), the +`usb-core` package (`mip.install("github:PyDevices/pydevices/usb-core")`), +and a device in the host port. An ESP32-S3 needs a powered hub or OTG +adapter, because some boards don't switch VBUS. + +Run it: + + mpremote run usbhost_simpletest.py + +Tested on: Waveshare ESP32-P4-WIFI6-DEV-KIT, MicroPython +v1.30.0-preview.160.gb70ae1aca6, with nothing plugged in: the host started, +printed `0 USB device(s) found` and stopped cleanly. +""" + +import time + +import usb.core + +# Start the host and give attached devices a moment to enumerate. +usb.core.start() +try: + time.sleep_ms(1500) + + # Print each device's vendor and product ID. + devices = list(usb.core.find(find_all=True)) + print("%d USB device(s) found" % len(devices)) + for device in devices: + print("%04x:%04x" % (device.idVendor, device.idProduct)) +finally: + # Release the port for the next program. + usb.core.stop() From 366687c0a12bcd4a152e3bf2dda9747ad5159541 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:05:55 -0600 Subject: [PATCH 2/2] picotts: include py/objstr.h for __revision__ --- modules/picotts/src/modpicotts.c | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/picotts/src/modpicotts.c b/modules/picotts/src/modpicotts.c index 7b98c264..9263f461 100644 --- a/modules/picotts/src/modpicotts.c +++ b/modules/picotts/src/modpicotts.c @@ -19,6 +19,7 @@ #include "py/obj.h" #include "py/objarray.h" +#include "py/objstr.h" #include "py/runtime.h" #include "picoapi.h"