Skip to content

0.2.21 - #341

Merged
lovyan03 merged 54 commits into
masterfrom
develop
Aug 26, 2026
Merged

0.2.21#341
lovyan03 merged 54 commits into
masterfrom
develop

Conversation

@lovyan03

Copy link
Copy Markdown
Collaborator

New device support

  • Add M5Stack CoreP4X board support (by @hlym123)
  • Add M5Tab5X board support, with controllable EXT interface power (by @hlym123)
  • CoreMatrix: enable the PM1 external power output (MBUS) (by @TinyuZhao)
  • ToughC5: add charge current control (by @TinyuZhao)
  • ESP32-C5: clear the RTC IRQ and warn about unsupported sleep modes (by @TinyuZhao)

Board detection

Power management fixes

  • IP5306: stop reporting a finished charge (or no cell) as an ongoing charge
  • Fix setChargeVoltage raising the charge voltage when a value below the lowest
    step was requested
  • Tab5: fix the battery current sign (charging now reads positive)
  • PM1: expose PWR_SRC as a bitmap, so multiple power sources are reported correctly
  • PM1: fix button click and wake IRQ handling, and correct the power API docs
  • RX8130: keep the timer /IRQ pulse catchable and follow the datasheet re-arm
    sequence

PM1 / IO expander API

  • Add PM1 PWM control and use it for the ToughC5 buzzer
  • PWM duty APIs now have explicit units; setPwmDuty is left vacant
  • Replace the IO expander pull enable API with explicit pull modes
  • IO expander virtuals and the PWM methods now return the write status, and board
    initialization checks them

Audio

  • Flush the final partial speaker buffer instead of dropping it

Internal

  • Use m5gfx::delay for internal hardware waits so short delays are not truncated to
    the tick period on ESP-IDF
  • Require M5GFX 0.2.28

ainyan03 and others added 30 commits August 17, 2026 04:02
The PM1 datasheet defines PWR_SRC as independent 5VIN, 5VINOUT, and battery-valid bits that may be set simultaneously. Return that bitmap directly instead of imposing an undocumented primary-source priority, and update the PaperMono charging check to test the external-power bits.
getBatteryCurrent() documents "+ = charge / - = discharge", but on the
Tab5 the INA226 shunt is wired so that charging reads negative. Verified
on real hardware: with a discharged pack under USB power (voltage rising,
charge status asserted) the API returned about -650 mA. Invert the sign
on the Tab5 path to match the documented convention.
Tab5: fix the battery current sign
The PM1 exposes two PWM channels, on GPIO3 and GPIO4. Follow the naming and
the units of the standalone M5PM1 driver so that code can move between the
two without surprises: setPwmDuty takes percent, setPwmDuty12bit takes the
raw 12-bit value, and the two boolean arguments are ordered polarity then
enable. The return value is bool for the I2C result, matching the rest of
this class.

Both channels share a single frequency register, so changing it affects a
channel that is already running as well.
The ToughC5 buzzer is driven by PM1 PWM channel 1, so GPIO4 has to be set up
for that function before an application can drive it at all. The PM1 keeps
running across ESP resets and retains its PWM state, so the channel is also
put off at boot, the same way the vibration motor is on the StopWatch.

GPIO4 is normalized in an order that does not drive the buzzer on the way: the
output latch is cleared before the pin becomes an output, and the PWM function
is selected last. Selecting that function is also what would make a retained
duty audible again, so it is only done once the channel is confirmed off. The
write is retried a few times, and if it still cannot be confirmed the pin is
left as a plain output driving low, which is silent whatever the retained PWM
state is. Failing closed loses the buzzer until the next boot, which is the
better direction for a step whose purpose is to guarantee silence.

Stopping the channel on the way into sleep is deliberately left to the
application. The PM1 stays powered while the ESP sleeps, so the application
may intend the PWM output to remain active; whether to stop it is application
policy rather than board initialization.
Add PM1 PWM control and use it for the ToughC5 buzzer
The pull resistor state was split across two virtuals: setPullMode picked
up or down, and enablePull turned pulling on or off. What that pair means
differs per expander. On the M5IOE1 the two share one register pair, so
setPullMode alone already established the state and enablePull(pin, true)
only set the pull-up bit without clearing the pull-down one, which could
leave both enabled at once. On the PI4IOE5V6408 the registers are separate
and the pair genuinely has to be used together, yet nothing in this
repository ever enabled it: the pull-ups in use rely on the reset default
of the enable register.

setPullMode now takes gpio_pull_t and establishes the whole state in one
call, so neither the ordering nor a reset default matters. The values match
the standalone M5IOE1 driver constants. Writes are ordered so that two
pulls are never enabled at the same time, and the enable register is now
written explicitly on the PI4IOE5V6408 rather than assumed.

The setter returns whether the requested state was fully established, since
it takes more than one I2C write and a partial failure is what would leave
a pin in the state this change is meant to rule out. The other virtuals in
IOExpander_Base still return void.

BREAKING: enablePull is gone and setPullMode takes an enum instead of a
bool. Old calls do not compile, because neither bool nor an integer
converts to the enum implicitly.

| Old | New |
| --- | --- |
| enablePull(pin, false) | setPullMode(pin, pull_none) |
| setPullMode(pin, true), with pulling enabled | setPullMode(pin, pull_up) |
| setPullMode(pin, false), with pulling enabled | setPullMode(pin, pull_down) |
Separate percentage and raw 12-bit duty values in the method names, and use a
shared polarity type so boolean arguments cannot be transposed silently.

BREAKING: setPwmDuty is removed and polarity arguments now use pwm_polarity_t.

| Old | New |
| --- | --- |
| setPwmDuty(channel, percent, polarity, enable) | setPwmDutyPercent(channel, percent, polarity, enable) |
| setPwmDuty12bit(channel, raw, polarity, enable) | setPwmDuty12bit(channel, raw, polarity, enable) |

The duty parameters widen to uint32_t so that the documented range is what gets
checked, rather than whatever is left after the value has been narrowed at the
call boundary. Existing in-range calls are unaffected.

The unqualified setPwmDuty name is intentionally left vacant to eliminate silent
semantic changes. This change does not define or promise any future use for that
name.
Replace the IO expander pull enable API with explicit modes
Separate percentage and raw 12-bit duty values in the method names, reject
out-of-range inputs, and migrate existing raw-duty call sites without changing
their output behavior.

BREAKING: setPwmDuty is removed. Use setPwmDuty12bit for existing raw values or
setPwmDutyPercent for percentages; polarity now uses pwm_polarity_t and precedes
enable.

| Old | New |
| --- | --- |
| setPwmDuty(channel, raw, enable, polarity) | setPwmDuty12bit(channel, raw, polarity, enable) |
| No percentage API | setPwmDutyPercent(channel, percent, polarity, enable) |

The unqualified setPwmDuty name is intentionally left vacant to eliminate silent
semantic changes. This change does not define or promise any future use for that
name.
Give the PWM duty APIs explicit units and leave setPwmDuty vacant
Return the underlying I2C write status from setPwmFrequency and document that the configured frequency is shared by every PWM channel.

BREAKING: the setPwmFrequency return type changes from void to bool. Ordinary calls that discard the result remain source-compatible, but code that depends on the exact member-function type must update from void (M5IOE1_Class::*)(uint16_t) to bool (M5IOE1_Class::*)(uint16_t).
Report M5IOE1 PWM frequency write results
Enable PM1 external power output(MBUS) for CoreMatrix.
Harden PWM initialization failure handling
Flush the final partial speaker buffer
Use m5gfx::delay for internal hardware waits
Return the write status from the IO expander virtuals
Fix PM1 button and wake IRQ handling and correct the power API docs
The helper that decides which board this is did not speak I2C. It made no
start condition, clocked every address bit twice over twenty pulses, and
read the ninth bit a millisecond after the clock had gone back down, when
any device that acknowledged had long released the line. What came back
was the pull-up on the pins, not an answer from the address, and the
caller read that value as the device being present.

Every board on the branch has pull-ups on its internal bus, so the first
condition matched on all of them. AtomS3RExt is checked first and Atom
VoiceS3R, which carries an ES8311 where the other carries a BMI270, was
reported as AtomS3RExt.

Send the address over the software I2C port instead, which drives the
lines open drain, keeps away from the peripheral, and reports the
acknowledge it actually receives. The return value now means what the
five call sites always read it as. A stop is offered first, because a
device left mid transfer needs one to let go and one board in this same
file already records a device that needed exactly that; it is made by
driving low and releasing, never by driving high, so a device holding a
line is not fought over. Pins that carry no pulled up bus are left alone,
the same test the display autodetection uses on pins that may not be I2C
at all. A single retry covers a device that is slower to answer than the
old test, which never waited for an answer in the first place.

The name says what it does now: the same name in M5GFX belongs to a
different function with a different contract.
ainyan03 and others added 24 commits August 19, 2026 21:28
The three boards that share the internal bus were separated by a probe on
another pair of pins in between: BMI270, then the Stamp-S3Bat address on
48 and 47, then the ES8311. Atom VoiceS3R drives its speaker out of
GPIO48, so a board fully identified by its own bus reached for the I2S
data line before it got there. Take the internal bus first and only step
off it when nothing there answered.

Within that bus the BMI270 is asked first. The AtomS3R-Ext and the
AtomS3RCam are the same board, and on the Ext the camera end is a small
breadboard the owner is free to wire up, so an address that is not the
BMI270 can appear on the internal bus of a board that is one. The sensor
that is always fitted decides, and something added later cannot take the
identity away.

Measured on hardware: Atom VoiceS3R answers at 0x18 alone, AtomS3RExt and
both AtomS3RCam variants at 0x68 alone, and StampS3Mini has a pull-up on
one line of the pair and nothing on the other.
The probe held the pins low nine times over before it had established that
they carry an I2C bus at all: the stop preamble ran first, and only then
came the test for a pull-up. These pins are how the board is identified,
so until that test passes they may be anything. Run the test first and
offer the stop only once the pins have answered for themselves.

Take the second software I2C slot rather than the one the display
autodetection uses. The slots carry no ownership - opening one takes over
whatever settings were there - so two libraries sharing a slot rests on
nothing but the order they happen to run in.

Drop the retry. Nothing between the two attempts changes the state of the
device being asked: no reset, no power, no clock. It doubled the time
spent on every address that is not answering, which the boards with
nothing on their internal bus pay three times over.
library.json and idf_component.yml already ask for it; the Arduino
manifest asked for M5GFX with no version at all. An older one has no
software I2C behind the negative port numbers, and the port number
reaches the hardware port table as an index instead.
Checking for the pull-up before offering a stop condition left the one bus
that most needs the stop unable to get it. A device interrupted mid read
holds the data line down, and a software reset does not take its power
away, so the line is still held when the board is identified again. The
check reads that as no bus and returns before the stop is ever made.

The signature is specific enough to act on: the clock comes back high
against the internal pull-down while the data line stays low. That is a
held bus, not a pin without a pull-up, where neither line comes back.
Offer the stop in that case and look again; every other answer returns
as it did.
Every probe waited 50ms before it touched the pins. The old probe always
answered on the first address, so that wait was paid once; now that an
address which is not there says so, it is paid again for every address
that misses - four times over on a board that answers nothing on its
internal bus.

The wait is about how long ago the board was powered, not about the
address being asked, so it belongs at the entrance to the board check.
Measured on an AtomS3RCam: one probe went from 49.8ms to 0.3ms, and the
50ms is now spent once for the whole identification.
The bus test set both pins to output while their latches were still high
from the pull-up mode before it, so each pin drove a push-pull high for
the moment before the test pulled it low. On pins that may not be an I2C
bus at all - which is the whole point of the test - that is a driven high
into whatever is on the other side.

Put the latch low first. The lines are still driven low by the test after
that, but a high is never driven at all.
Board detection: probe the address instead of the pull-up (#330)
Both setChargeVoltage implementations subtracted a bias from the argument
before comparing it against their step table. The argument is unsigned, so
a request below the lowest step wrapped around and then clamped to the
opposite end of the table: on the AXP192 a request for 4.0V configured
4.36V, and on the AXP2101 the same request selected the reserved code 0.
Asking for a gentler charge voltage gave a harsher one.

The AXP2101 table also carried a 4.6V step that the part does not have -
its constant-voltage register goes up to 4.4V - so a request that reached
that entry wrapped the index back onto the reserved code as well.

Compare in millivolts against the real steps instead, take the highest
step that does not exceed the request, and fall back to the lowest step
when the request is under all of them. Every request inside the supported
range keeps its previous result.
Review pointed out that the AXP2101 constant-voltage register is documented
differently across datasheet revisions: the current ones reserve code 0
while an early one gave it to 4.6V. The change had asserted flatly that no
step above 4.4V exists, which only holds for the revisions available now.

State what every revision agrees on instead, and give the reason for
holding a high request at 4.4V: no battery this library runs on charges to
4.6V, so the code whose meaning depends on the silicon is worth avoiding.

Both headers now carry the supported steps and what happens on either side
of them, which nothing stated before.
Add ESP32C5 RTC IRQ clear and unsupported-sleep log warnings
Tab5X upgrades the ESP32-P4 silicon revision used by Tab5. Tab5X EXT interface power is controllable.
Stop a low charge voltage request from raising the charge voltage
Add ToughC5 charge current control
The register document describes two flags for this part: REG_READ0 bit3 to
tell charging from discharging, and REG_READ1 bit3 to tell whether the cell
has already been filled. Only the first was read. It stays set once charging
is enabled and a supply is present - the completed charge included - so a
finished charge was reported as an ongoing one, and so was a board running
with no cell installed.

Read both. They sit at adjacent addresses, but the document only ever shows
a single-byte read and never states that the address auto-increments, so
they are read separately rather than resting on unspecified behaviour.

Measured on a Core BASIC v2.7 across every branch: supply removed
(70=16 -> false), charging (70=19 71=70 -> true), charge disabled
(70=11 -> false), and charge complete (71=A8 -> false, where the earlier
form stayed true).

Behaviour change: isCharging() now returns false once the cell is full,
where it used to stay true for as long as a supply was present. Callers that
watched for the transition as a sign that external power was lost will see
it at full charge instead; the library has no way to express "full" through
this return type yet.
Stop reporting a finished charge as an ongoing one on the IP5306
- Recognize board ID 31 and add pin mappings for internal I2C
  (SCL G9, SDA G11), Port.A, TF card, and MBUS.
- Add M5IOE1@0x4F and M5PM1@0x6E power management.
- Add ES8311/ES7210 audio, BMI270 IMU, and RX8130 RTC support.
…e-arm sequence

The RX8130 wakeup timer releases /IRQ automatically after tRTN2, which is
only 122us with the 4096Hz source clock but 7.57ms with the other clocks
(Table 16). setTimerIRQ() picked 4096Hz for every period below 16s, so
boards whose RTC /IRQ is routed through a scanning PMIC or IO expander
(e.g. the M5PM1 GPIO IRQ path) missed the interrupt for common requests
such as 1s or 10s.

Clock selection now tries 64Hz, 1Hz, 1/60Hz and 1/3600Hz in that order and
takes the first whose rounded count keeps the period within 1/256 and has
at least 16 counts (the first countdown can be short by up to one source
clock - 1s for the two slowest clocks - so this bounds that error to ~6%
or less); 4096Hz is used only for periods no other clock can represent
(below ~2s). The arithmetic stays in 32 bits: each clock carries its
65535-count range so msec*div cannot overflow, and the period error is
the division remainder. When a finer clock runs out of range the coarser
one rounds up, so the returned period never steps backwards as the
request grows.

The re-arm sequence follows the datasheet example flow: TE=0 (+TSEL) ->
clear TF -> TIE=1 -> preset -> TE=1 last, so the first event can never
precede the interrupt enable. The old code rewrote the counter with TE
still set, which the datasheet does not allow and which left the timer
running with the stale preset. The preset is read back while TE=0 and
rewritten if it does not match. Every access in the arming path is
checked; on failure the timer is stopped (TE=0/TIE=0, verified by
read-back, retried) and 0 is returned - the return value cannot
distinguish that from a requested stop, which the API comment now states.
Flags in 0x1D are write-0-to-clear, so TF (and AF in clearIRQ/disableIRQ)
is cleared with a single constant write instead of a read-modify-write
that could drop a flag raised in between. The returned period is rounded
to the nearest ms and is never 0 while the timer runs.
Keep the RX8130 timer /IRQ pulse catchable and follow the datasheet re-arm sequence
Bump version to 0.2.21 and require M5GFX 0.2.28
@lovyan03
lovyan03 merged commit 3eaaf82 into master Aug 26, 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.

3 participants