Visitar URL original
0.2.22 by lovyan03 · Pull Request #364 · m5stack/M5Unified · GitHub
Skip to content

0.2.22 - #364

Merged
lovyan03 merged 36 commits into
masterfrom
develop
Sep 14, 2026
Merged

0.2.22#364
lovyan03 merged 36 commits into
masterfrom
develop

Conversation

@lovyan03

Copy link
Copy Markdown
Collaborator

New API

- `Speaker.setBufferReleaseCallback()` / `Mic.setBufferReleaseCallback()`: called when a queued buffer has
  been consumed, so several buffers can be rotated without polling (#356)
- `Mic.setPostStartCallback()` (#348)

Audio

- StopWatch: fix ES8311 capture after a power-on reset, and rework the ES8311 gain structure (mic noise /
  clipping, speaker distortion at high volume) (#348, #350)
- Mic_Class: serialize `record()` and lifecycle changes without blocking `end()`; an explicit `sample_rate`
  of 0 now fails instead of being undefined (#351)

Board fixes

- Tab5: set the ESP-Hosted SDIO pins in `M5.begin()`, so WiFi works with the generic `esp32p4` board
  selected; sketches without WiFi are not affected (#358)
- CoreS3: fix the external 5V output not turning off, and re-enable being cancelled, on USB-C without a
  battery (#352)
- Restore GPIO46 after board detection on boards that expose it (camera on CoreS3) (#345)
- StickS3: fix the IMU axis orientation (by @hlym123, #342)
- BMI270: report a failed config upload instead of leaving the IMU marked as initialized (#361)
- PCF8563: `setTimerIRQ()` rounding no longer overflows near `UINT32_MAX` (#361)

Examples

- The audio examples share `AudioUI.h` / `AudioOutputM5Speaker.h`; the MP3 example also plays WAV (#357)
- MP3 and Speaker_SD_wav_file mount the card as 4-bit SD_MMC where the board wires it (Tab5), SPI elsewhere,
  with the pins from M5Unified; WebRadio selects STA mode before `disconnect()` (#359, #360)

Internal

- I2C_Class prefers `driver/i2c_master.h` over the legacy `driver/i2c.h` (#355)
- Built as an ESP-IDF component, the Arduino component is linked automatically (#344)
- Require M5GFX 0.2.29 (the PoE-P4 display output takes the I2C port and pins in its config) (#363)

luoweiyuan and others added 30 commits August 27, 2026 13:43
…ponent

arduino-esp32 publishes its -DARDUINO... definitions as PUBLIC compile
options, so they only reach components that link against it. The public
headers of this library change class layout with ARDUINO (LGFXBase derives
from Print only under ARDUINO, for example), so an application built with
arduino-esp32 as a component read the same objects with a different layout
than this library was compiled with: getPanel() returned null and drawing
faulted. Pick the Arduino component that is already part of the build
(arduino / arduino-esp32 / espressif__arduino-esp32, overridable with
<LIB>_ARDUINO_COMPONENT, OFF to disable) after register_component() and link
it publicly. Only BUILD_COMPONENTS is consulted, so a build that excludes
Arduino is unaffected, and only APIs that exist since ESP-IDF 4.x are used.
Link the Arduino component automatically when built as an ESP-IDF component
…application

begin() drives GPIO46 high as the power hold pin before the board is known
(Capsule / Dial / DinMeter power off otherwise) and left it as an output on
every board. On CoreS3 GPIO46 is the camera VSYNC, so an esp_camera_init()
done before M5.begin() stopped delivering frames (frame timeout while the
sensor stayed readable over I2C).

Back up the pad before asserting and restore it once the board is positively
identified as one that exposes GPIO46 to the application: CoreS3 / CoreS3SE /
StackChan (camera VSYNC), AtomS3R Cam (camera data), AtomS3R Ext / StampS3 /
StampS3Bat (header pin). Every other case keeps the previous behaviour on
purpose: the power hold boards must stay asserted, StopWatch / ChainCaptain /
PaperS3 / PaperDIY have Display.init() configure GPIO46 as a display bus pin
after the backup was taken (an unconditional restore would undo that), and a
board resolved through cfg.fallback_board is not restored because the physical
board may still be one that needs the hold. Cardputer / PaperMono use GPIO46
as the mic data line, which M5Unified itself reconfigures, so they are left
as is.

The pin is still driven high for the duration of board detection; the change
makes the interference finite instead of permanent, it does not remove it.

Verified on CoreS3 (ESP-IDF 5.5.1, esp32-camera 2.1.7): camera first then
M5.begin() and M5.begin() first then camera (sccb_i2c_port = In_I2C port)
both stream frames while the PMIC is read over the shared bus.
Restore GPIO46 after board detection on boards that expose it to the application
Some codecs accept part of their setup only while the I2S bus clock is
running (ES8311), but the enable callback runs before the capture task
starts the clock. Add an optional post-start callback that begin()
invokes in the caller's context once the capture task has enabled the
I2S channel, waiting up to one second for the clock. A missing clock or
a false return from the callback tears the port back down and fails
begin().

Publishing the codec state safely also requires the lifecycle to be
serialized: end() now takes _begin_lock (internal callers use the new
_end_locked()), and the already-running early return in begin() moved
under the lock, so a concurrent end() can no longer tear the port down
while begin() is still bringing it up, or right after its early check.
The capture task publishes the clock state (_i2s_active) only when the
I2S start actually succeeds, and a failed enable callback or setup now
runs the disable callback and driver uninstall before begin() reports
failure, instead of leaving a half-built port behind.
After a power-on reset the ES8311 runs an internal sequence once the
CSM is enabled and the bus clock arrives. A SYSTEM(0x0D) analog
power-up write issued before the I2S clock starts is absorbed without
effect even though the register reads back the written value, so a
capture path initialized by the enable callback (which runs before the
clock starts) records only zeros, forever. Measured on StopWatch
hardware with both arduino-esp32 2.x and 3.x:

- a 0x0D write arms the capture path only when issued while the bus
  clock is running (earliest observed working point ~25 ms after the
  clock start); pre-clock writes never arm it
- once armed, real samples appear when the analog warm-up finishes,
  roughly a second after the codec powered up - a fixed physical time
  that neither blocking nor extra writes shorten
- the arming is needed once per codec power-on reset; warm re-inits
  (including Mic end/begin cycles) keep it
- cutting the codec power rail at speaker disable forces the next
  capture through another reset and warm-up, and a quickly cycled rail
  produces marginal resets whose recovery misfires

Register the new Mic post-start callback for the StopWatch: it writes
0x0D=0x01 at roughly +30/+60/+90 ms after the clock start and returns,
so the first Mic.begin() after a codec reset blocks for about 100 ms;
later ones are unaffected. The armed state is latched only when the
latest (most conservative) write was acknowledged. The analog warm-up
then completes in the background: begin() success means the port runs
and the codec is configured, and for about the first second after a
codec reset captured samples can still be all zero - documented as
part of the begin() contract. A reset performed outside this library
(rail cycled by the application, brown-out) is detected through
register 0x17, which keeps the value a previous capture setup wrote
only while no reset has occurred.

The speaker disable path keeps the audio power rail on and powers down
only the DAC over I2C; the deep I2C power-down still happens on mic
disable. This changes the idle power draw after Speaker.end() and is
deliberate - see the comments for the measured failure modes behind
it.

in_i2c_bulk_write() now reports whether every write was acknowledged,
and the StopWatch mic enable callback fails the enable when its setup
bulk did not fully land.

ChainCaptain shares the same callback structure and codec and likely
needs the same treatment; it is left unchanged here because it could
not be verified on hardware.
… path

The rollback for a failed enable callback installed nothing itself:
_setup_i2s cleans up its own failures, and on the legacy driver an
unconditional i2s_driver_uninstall(port) could take down a driver some
other code installed on the same port.
StopWatch: recover ES8311 capture after power-on reset
The capture path took all its gain after the ADC: analog PGA at 0 dB
with +32 dB of ADC digital volume, which amplified the noise floor
along with the signal and clipped loud input early (white noise and
flattened waveforms in recordings).

Move the gain to the analog side and trim the digital stage:
- 0x14: analog PGA 0 dB -> +21 dB (gain code 7, 3 dB/code; the
  measured SNR plateaus by this level, limited by the mic element)
- 0x17: ADC digital volume +32 dB -> +6 dB
- 0x1C: HPF stage-2 coefficient 10 -> 4. The dynamic HPF was already
  enabled by the old value; the new coefficient cuts sub-200 Hz rumble
  at the default 16 kHz rate (silence rms 312 -> 52)

Measured result: SNR 14.6 dB -> ~19 dB against a 440 Hz reference.

The reset-witness comparison follows the 0x17 bulk value (0xCB).
The speaker enable procedure set DAC volume (reg 0x32) to 0xEF (+24 dB).
Since digital gain ahead of the DAC cannot raise the full-scale ceiling,
this only moved the clipping point down: any sample above 1/16 of full
scale clipped hard, so master volumes above ~64 turned a sine wave into
a buzzer-like square wave without getting any louder.

Measured with a two-device sweep (StopWatch playing a 1 kHz tone,
CoreS3 microphone running a Goertzel harmonic analysis):
- clipping onset by 0x32 value: 0xEF = vol 64-128, 0xDF = 128-192,
  0xCF = 192-255, 0xBF = none up to 255
- 0xEF at vol 64 and 0xBF at vol 255 produce the same acoustic level,
  confirming +24 dB adds no loudness
- 0xCB (+6 dB) reaches DAC full scale right at vol 255: fundamental
  rises monotonically 20/100/222/300/380 (vol 64/128/192/224/255) with
  no 3rd-harmonic explosion, and its maximum clean output is about
  double the old clean ceiling

0xCB therefore maximizes clean output while removing digital clipping
from the entire master volume range. ChainCaptain shares the same 0xEF
value but is left unchanged (no hardware at hand to verify).
…end()

Two record() callers could claim the same slot (their plain metadata
writes then race, and the capture task can pair one callers buffer with
the others length), and a record() racing a lifecycle change could
publish a request into a port that was just torn down while still
returning true. The default-rate record() overloads also read
_cfg.sample_rate outside any lock, and the capture task read it again
after startup, so a queued record() with a new rate could retune the
clock under a request already being captured.

All request publishing now happens under a dedicated _rec_lock, and
every lifecycle path joins the same order: record(), end() and the
public begin() each take _rec_lock before _begin_lock. The requested
sample rate is validated and committed inside _begin_raw() under
_begin_lock: a rate of 0, or one whose product with the effective
over_sampling would wrap uint32_t, is rejected without poisoning the
stored config. The default-rate overloads pass 0 (keep the current
rate) instead of reading the config outside the lock, and an explicit
sample_rate of 0 is rejected up front. The capture task uses
_rec_sample_rate - fixed before the task is created - instead of
re-reading _cfg.sample_rate, and _calc_rec_rate() clamps over_sampling
to the same 1..8 range the task itself uses.

The locks cover one begin plus one claim attempt. When both slots are
busy, or a rate change still has recordings draining, the caller
releases the locks, waits outside them, and retries, so end() never
waits behind a full queue or a stalled capture. The lock itself is an
unfair CAS: contending callers have no arrival order, which is
documented at the retry site.

Requests carry a wrap-safe sequence number stamped under _rec_lock,
and the capture task consumes the pending slot with the older stamp.
Slot identity cannot express publish order - a freed slot can be
refilled while the other still holds an earlier request, and cursor
parity schemes desynchronize across the tasks idle transitions. The
tasks two slot reads are not one snapshot, so an empty slot 0 is
re-read once slot 1 is known pending before the sequence compare. The
capture task itself takes no locks; slots still hand over through the
length release/acquire protocol.

begin() now reports honest results on every board: it waits for the
capture task to actually enable the I2S channel (previously only
boards with a post-start callback did, so an _i2s_start() failure left
record() returning true for requests that could never complete), and
it fails cleanly when the task semaphore cannot be allocated. The
teardown handshake is restructured: the capture task acknowledges its
I2S cleanup through a dedicated atomic, parks itself with vTaskSuspend
instead of self-deleting, and _end_locked() deletes it only after the
ack and the park - notifying a self-deleting task raced against its
TCB teardown, and the ack (rather than eTaskGetState alone) matters
because ESP-IDF 4.0-4.2 report an indefinite notification wait as
eSuspended, which would otherwise pass the check before cleanup ran. _task_running and _task_handle are atomics,
and the raw rx_update wait is bounded, with its timeout failing the
begin instead of starting the channel on an unlatched clock. A
persistently failing I2S read backs off one tick per retry so a
high-priority capture task cannot starve the stop request. The
effective rate is capped below 2^31 so the divider search never wraps
internally, and the slot-order compare uses an unsigned half-range
test with no signed-conversion dependence.

The callbacks run while these non-recursive locks are held; the
contract now spells out that they must not call record() either.
config() and setSampleRate() remain unsynchronized and are documented
as stopped-port operations, getters included. The locks are
per-instance: two Mic_Class instances driving the same I2S port stay
unsupported (the driver state is port-global).

Same family as the Speaker slot state machine fix (#295); the Mic side
keeps a writer lock plus sequence stamps instead of per-slot CAS
because its slots have a single consumer.
StopWatch: fix the ES8311 gain structure (mic noise/clipping, speaker distortion at high volume)
Mic_Class: serialize record() and lifecycle changes without blocking end()
… output control

setExtOutput(false) wrote BUS_OUT_EN and BOOST_EN in one two-byte transaction, so
BUS_OUT_EN dropped while the bus was still at 5V. BUS_OUT_EN=0 turns the bus
input-side switch on; with the bus still at 5V that switch never enters reverse
blocking and USB VBUS keeps feeding BUS_OUT. The 5V stayed on after the disable,
and without a battery the TS reading (a divider of BUS_OUT) stayed above 2.0V so
every later setExtOutput(true) was cancelled until a power cycle.

- On BUS_OUT_EN 1->0: write BOOST_EN=0 first, wait 200 ms for the bus to
  discharge (measured boundary: 20 ms loaded, 80 ms unloaded), re-read the
  expander and clear only the requested bit. On an I2C failure re-read the latch
  and recover from the actual state, retrying with read-back up to 3 times.
- Other paths write one register at a time, BOOST_EN before the output enable
  when turning on and after it when turning off, and stop if the first write fails.
- Serialize all AW9523 output-port writers in M5Unified (setExtOutput,
  setUsbOutput, Power_Class::begin, the speaker amplifier enable) with a mutex
  and run the no-battery protection check inside the same critical section.
- Protection check: skip it when BUS_OUT_EN is already 1 (the 5V on TS is our own
  output), treat I2C failures and invalid ADC values as unsafe, and when it reads
  unsafe release the lock and re-check every 20 ms for up to 1 s so a re-enable
  right after a disable is not cancelled by the stale TS reading. External bus
  power keeps TS high, so it is still cancelled. Requests carry a generation
  number so an enable that is still waiting yields to a later disable.
CoreS3: fix external 5V output not turning off / re-enable being cancelled on USB-C without battery
The header only needs i2c_port_t / I2C_NUM_x. ESP-IDF 6.x marks
driver/i2c.h as EOL and emits a CRITICAL WARNING pragma on every
include, which repeats for every translation unit that includes
M5Unified.hpp; the header is scheduled for removal in v7. Include
driver/i2c_master.h when available (IDF 5.2+), fall back to
driver/i2c.h for IDF 4.4 (Arduino core 2.x).
I2C_Class: prefer driver/i2c_master.h over the legacy driver/i2c.h
Register a function that the playback/capture task calls once it has
finished with a buffer given to playRaw()/playWav()/tone() or record():
from then on the caller may overwrite (Speaker) or read (Mic) it. With
it, two buffers used alternately are enough for runtime-generated
audio; the playRaw notes said three were needed, which was conservative
even before (waiting for isPlaying(channel) < 2 gives the same
guarantee for a single producer), so the notes now say so.

Contract (see the header):
- Per request, not per memory region: a queued request or a re-triggered
  tone using the same data keeps it in use. For playWav() the pointer is
  the PCM chunk inside the wav.
- Requests discarded unplayed do not call back: queued ones replaced by
  stop()/stop_current_sound, and everything dropped by end(). The one
  being played when stop() cuts it does. Delivery follows the slot
  release, so it can arrive after isPlaying()/isRecording() dropped:
  track buffers by pointer, not by counting.
- Task context, never an ISR; the sound may still be in the I2S queue.
- play*()/record() may be called from within: they never wait there and
  never start the lifecycle (an end() may be tearing the task down), so
  they return false if no slot is free. A Mic rate change from within is
  refused before it is committed.
- Set or clear only before the first request or after end() returned.

Supporting changes:
- play*() answers true only when the request was queued (it used to say
  true for a speaker that could not start and for an empty request; a
  producer marking a buffer busy on true would then wait forever).
- Speaker: the task handle is atomic (read from every play*() caller),
  and end() is serialized with begin() under the same lock so a teardown
  cannot slip in between task creation and handle publication.
- _slot_occupied loads with acquire, so a producer polling isPlaying()
  is ordered after the task's last read of the buffer.

Verified on ESP32-S3: two-buffer ping-pong driven from the callback and
refilling from inside the callback, 0 overlaps with the task reading the
buffer; three-buffer and isPlaying-wait forms unchanged.
…, and fixes found while verifying

Speaker examples that generate or stream audio at runtime used three
buffers in rotation so that the one being refilled could not be the one
the speaker task was still reading. They now refill a buffer only after
the release callback has reported it free, tracking each buffer by
pointer with an atomic busy flag (release on the callback side, acquire
on the waiting side):
- Speak_with_AquesTalk, Speaker_SD_wav_file, Bluetooth_with_ESP32A2DP:
  two buffers. Bluetooth uses fixed-size storage instead of per-packet
  heap blocks (the callback and the display compare against addresses
  that must not change), truncates an oversized packet, and shows a zero
  waveform after a stop instead of clearing buffers the task may read.
- WebRadio/MP3 (ESP8266Audio wrappers): three buffers in rotation - two
  would satisfy the handshake, the third keeps one buffer of slack
  against decoder and network jitter. The callback is registered from an
  explicit setup() after M5.begin(), before anything plays. stop() lets
  the queued buffers play out rather than cutting them, so every buffer
  comes back through the callback.
- Zero-length requests are skipped before marking a buffer busy.

Basic/Microphone draws the block the callback reports as filled (atomic
mailbox holding the newest block) instead of a block assumed to be two
behind the record position, and clears the mailbox across a Mic restart.

Fixes found while verifying the examples on hardware:
- Bluetooth: the phone's AVRCP volume now drives the speaker (the library
  applied it inside the audio_data_callback this example overrides) and
  a local button change is reported back with set_volume(). The example
  builds on the current ESP32-A2DP (set_stream_reader(nullptr, false)
  replaces the removed is_i2s_output; works on 1.7.x too).
- WebRadio: WiFi modem sleep off - with it on the stream buffer drained
  at about 2 KB/s while the link could deliver far more. Buffer 5 KB ->
  32 KB, filled before decoding starts (triggering the library's own
  first read, which replaces rather than adds) and refilled below 1/8.
  Stations that cannot be opened or stop before two seconds of audio
  came out are skipped with a note; a stream that ends after that moves
  on to the next station; when every station failed in a row the task
  waits before going round again. The decode task owns the station
  number and the UI requests absolute numbers, so a selection is never
  lost to an auto-skip. Station list refreshed (the first one returned
  404, several others no longer answer). WIFI_SSID/WIFI_PASS may be
  passed from the build.
- Speaker_SD_wav_file plays every *.wav in the root of the card;
  MP3_with_ESP8266Audio plays every *.mp3 in /mp3, one after another.
Add setBufferReleaseCallback to Speaker and Mic
…le plays WAV

The MP3, WebRadio and Bluetooth examples carried their own copies of the level meter / FFT / waveform display, the header text handling and the button handling, and the two ESP8266Audio examples their own AudioOutput wrapper. They now share AudioUI.h (header lines also written to the log and cleared on a track change, meters, waveform locked to a rising edge and scaled to the display width, volume bar, button and touch handling: a touch on the virtual button strip belongs to the buttons, one elsewhere acts as BtnA, both in one click sequence) and AudioOutputM5Speaker.h. Each example folder holds an identical copy, since an Arduino sketch cannot include files outside its folder.

MP3_with_ESP8266Audio also plays *.wav (AudioGeneratorWAV), decodes in its own task so that drawing never starves the decoder, and skips files that cannot be opened or decoded.

Also fixed on the way: APP_CPU_NUM is not defined on ESP32-P4; the FFT bar height overflowed int32 on a 720 px tall display; the background shade used a left shift of a value that can go negative; Bluetooth clears the header on a track change and on the disconnect event; WebRadio station steps from the UI add up while the decoder is busy; the display works from its own copy of each queued buffer instead of reading the producer buffers.
Share the display and input handling of the audio examples; MP3 example plays WAV
…nly)

With the generic esp32p4 board selected, the Arduino core defaults ESP-Hosted
to SDIO pins that do not reach the C6 on Tab5, so esp_wifi_init fails.
Call hostedSetPins() with the Tab5 wiring before ESP-Hosted is initialized.

The HAL symbols are referenced weakly so builds that never use WiFi/BLE do
not pull esp32-hal-hosted.o in (measured: a strong reference adds ~2.5KB of
flash and drags esp_timer system init, i.e. a timer task at boot; the weak
reference adds a null check only). Include order does not matter.

Tab5X is assumed to share the wiring (not verified on hardware).
ESP-IDF builds are unaffected (pins come from Kconfig there).
… SPI elsewhere

Take the SD pins from M5Unified (M5.getPin) instead of relying on the board
variant defaults and CS on G4. Boards whose data lines D1-D3 are wired
(hasSDMMC, e.g. Tab5) mount through SD_MMC in 4-bit mode; the others keep
SPI with the pins from the table, and boards without an entry keep the old
behaviour (core default SPI pins, CS G4).

The file source becomes AudioFileSourceFS on a fs::FS pointer so the rest of
the sketch does not care which filesystem is mounted.

Checked on Tab5 (SD_MMC 4-bit, generic esp32p4 board) and CoreS3SE (SPI).
1-bit SD_MMC is deliberately not used: on CoreS3 the SD lines are shared with
the display SPI bus.
Tab5: set the ESP-Hosted SDIO pins in M5.begin
MP3 example: mount the card as 4-bit SD_MMC where the board wires it, SPI elsewhere
…ebRadio selects STA mode before disconnect()

Speaker_SD_wav_file takes the card pins from M5Unified (M5.getPin): 4-bit
SD_MMC where the data lines D1-D3 are wired (Tab5), SPI elsewhere, with the
same mountCard() as the MP3 example. It also retries the mount from loop() so
a card inserted after start is picked up, and logs each file it plays.

WebRadio: on ESP-Hosted boards (Tab5) the radio is only brought up by the
first mode change, so disconnect() before it logged "ESP-Hosted link not yet
up" and "STA disconnect failed" on every start. softAPdisconnect() is dropped;
the sketch never enables the AP.
Examples: Speaker_SD_wav_file mounts the card like the MP3 example; WebRadio selects STA mode before disconnect()
ainyan03 and others added 6 commits September 13, 2026 23:05
…does not overflow

BMI270_Class::_upload_file returned true even when writing INIT_ADDR_0 or
INIT_DATA_ADDR failed, so begin() marked the IMU as initialized with no
config loaded. It now returns false and begin() reports imu_spec_none.

PCF8563_Class::setTimerIRQ rounded with (msec + 500) / 1000, which wraps for
msec above UINT32_MAX - 500 and disables the timer instead of saturating.
BMI270: report a failed config upload; PCF8563: setTimerIRQ rounding does not overflow
M5UnitPoEP4HDMI takes the I2C port and pins in its config from M5GFX 0.2.29.
…DMI; require M5GFX 0.2.29

M5UnitPoEP4HDMI (M5GFX 0.2.29) reaches the LT8912B through lgfx::i2c on a
port / pins given in its config. When the sketch leaves them unset they are
taken from In_I2C; a partly set config is completed from In_I2C with a
warning. M5GFX 0.2.29 is required for the new config fields.
Unit PoE-P4 display output: hand In_I2C port and pins to M5UnitPoEP4HDMI; require M5GFX 0.2.29
@lovyan03
lovyan03 merged commit e79eb6e into master Sep 14, 2026
54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants