Skip to content

0.2.27 - #258

Merged
lovyan03 merged 86 commits into
masterfrom
develop
Aug 17, 2026
Merged

0.2.27#258
lovyan03 merged 86 commits into
masterfrom
develop

Conversation

@lovyan03

Copy link
Copy Markdown
Collaborator

New device / panel support

  • Add support for M5Stack CoreMatrix (ESP32-C61)
  • Add support for M5ToughC5 (display, touch, PWM backlight brightness)
  • Add CoreS3 / Core2 ILI9342E panel support (by @Forairaaaaa, @SeanXiaojyy)

New features

  • Low power I2C support (ESP32-C5/C6/P4) and software (bit-banged) I2C / SPI, selectable via negative port numbers

ESP-IDF v6 compatibility

  • Fix CoreS3 LCD not working on v6 (SPI bus pins left in open-drain)
  • Clear stale open-drain pad state in QSPI / parallel bus / PWM backlight init
  • Fix pinMode pull-up/pull-down not driven on v6
  • Fix the build on v6 (HSPI/VSPI aliases) and extend the CI matrix

Fixes

  • Fix M5Tab5 black screen after a warm reset (wait for the touch controller to respond)
  • AtomS3R: re-probe the panel ID at low clock speed before giving up
  • Fix CardputerADV being misdetected as plain Cardputer
  • Fix board misidentification when OPI-PSRAM is disabled
  • Fix I2C error-stop handling (stall after a failed read, noisy NACK log, low power port reset)
  • Poll the port the transfer is on in Bus_I2C::wait() / busy()
  • Fix unbounded BUSY-pin spin in Panel_IT8951 (by @thecybershotguy)
  • VLW fonts: use the actual glyph width for the space character (by @kirberich)
  • Rework SSD1677 4-gray refresh into per-mode single-activation waveforms
  • BFF font RLE decode guard, AMOLED mirrored rotation support, LEDC guard fixes
  • Bring the SDL simulator window to the foreground on macOS (by @TinyuZhao)

Rob Kirberich and others added 30 commits October 26, 2025 14:59
Panel_SSD1677_4Gray now dispatches quality/text/fast to a shared absolute
refresh path (Mode 2 face-parity recovery, parity-aware full-frame
transfer, one Mode 1 activation) whose only per-mode difference is a
static LUT, and fastest to a differential monochrome update with a static
LUT and software-computed transition groups.

- quality: oscillation prefix erases image history with a group-balanced
  net drive, preventing ghost accumulation over repeated refreshes; the
  tone-forming tail runs twice so the endpoints stay as stable as the
  vendor waveform ones
- text: white-first waveform, forms the four levels without a black flash
- fast: absolute 4-gray waveform derived from the community SDK, with the
  black darken return shortened to 28 frames (the excess overdrive
  rebounds toward gray during later undriven holds)
- fastest: 2-frame reverse-polarity prepulse plus 8-frame dose with
  state-aware holds outside the updated region; VCOM matches the panel
  offset so held pixels sit field-free
- absolute refreshes wait on the BUSY line without the 400 ms
  refresh-minimum floor
- the fastest fallback now records its display baseline, so a fastest-only
  usage no longer degrades into a full refresh on every update
lgfx_qoi.c unconditionally defined QOI_DEBUG, so error paths always
called fprintf(stderr). This pulls in newlib stdio and can fail to
link on cores that do not provide the _write syscall. The png/jpg
decoders reference no stdio; this aligns qoi with them.
Define QOI_DEBUG externally to re-enable the debug output.
The loop counter was int while count is uint_fast8_t. On ARM cores
uint_fast8_t is unsigned int, so the comparison triggers
-Wsign-compare (on xtensa/riscv it is unsigned char, promoted to int,
which is why ESP32 builds never warned). Harmless at runtime; use the
same type as count.
Rework SSD1677 4-gray refresh into per-mode single-activation waveforms
Use stable absolute Mode 1 waveforms for Quality and Text, absolute Mode 2 for Fast, and differential Mode 2 for Fastest. Preserve ping-pong face parity across mode changes and rebuild display history after sleep or power transitions.

Optimize transition-plane generation and keep waveform-tuning helpers out of the public panel API.
Finalize SSD1677 four-mode refresh handling
The GPIO read used for the VAMeter / Cardputer / CardputerADV split was
overwritten by the VAMeter I2C probe result, so the subsequent
(result & 0x0C) == 0x0C test saw a bool and could never select
CardputerADV.

This surfaces whenever an I2C Cap or Unit is attached: on CardputerADV
G5/G6 are the external pins, so the Cap pull-ups make (result & 3) == 3
true, the VAMeter probe runs, finds nothing and leaves result at 0.
M5Unified then picks the plain Cardputer pin table, whose in_i2c entry is
255/255, and every access to a built-in device on the internal bus fails.

Keep the probe result in its own variable so the G8/G9 bits survive.
Fix CardputerADV being misdetected as plain Cardputer
The I2C code was written when every port of a chip was the same peripheral, so
the FIFO depth and the clock feeding the SCL divider were written as constants,
and the number of ports was taken from SOC_I2C_NUM. None of that holds since the
ESP32-C6, which carries a low power I2C alongside the normal one.

- SOC_I2C_NUM counts the low power port too, so it can no longer tell how many
  normal ports there are. That is why the conditionals had a list of chip names
  growing next to it. Ask SOC_HP_I2C_NUM instead, keeping the list only as the
  fallback for the ESP-IDF versions that predate the macro, so it stops growing.
- read the FIFO depth from SOC_I2C_FIFO_LEN through a per port accessor, rather
  than assuming 32 in writeBytes() and readBytes().
- move the source clock selection into a per port accessor as well. It has to
  stay a function rather than a value cached at init, because on the older chips
  it follows the CPU frequency, which changes at run time.

No behaviour change on the chips where the normal port is 32 bytes deep, which
is all of them except the ESP32-C2 at 16. The transfers on that chip were being
chunked past the end of its FIFO and are now chunked correctly. That one is not
verified on hardware.

This only prepares the structure; the low power port is not reachable yet.
The low power I2C controller shares its register layout with the normal
ports and the port numbering places it right after them, so the existing
transaction code can drive it once the port is reachable. What differs is
everything around the registers:

- Bring-up goes through the ESP-IDF master bus driver, which knows how to
  route the low power pads (fixed IOMUX pair on C5 / C6, LP GPIO matrix on
  P4) and their clock source. The port is usable when the driver headers
  and the low power clock definitions are available (ESP-IDF 5.4 or later).
- The pads live in the low power IO domain and keep their routing across a
  reset, so release() hands them back explicitly and set_pin() clears a
  leftover routing before taking a pin for a normal port. Without this the
  pins stay bound to the low power port even into the next program.
- set_pin() itself is skipped for a low power port: it would re-route the
  pins through the normal GPIO matrix on every transaction and disconnect
  the port from its own pads.
- The controller reaches the bus only through its internal open drain mode,
  so the forced outputs the normal ports use are left off. With them on,
  the state machine runs to completion without ever driving the lines.
- The TwoWire pin propagation in setPins() applies only to the normal
  ports; handing a low power port's pins to a Wire instance would redirect
  that port instead.

Verified on hardware for all three chips: a scan on the low power port
finds the same devices as a normal port on the same physical bus, and
register reads return real data.
A negative port selects a software implementation that drives the pins
directly: port -1 is slot 0, port -2 is slot 1. It reaches a bus without
claiming a peripheral unit, for when every unit is taken or when touching
one is undesirable. The convention matches the existing touch driver
setting where a negative spi_host already means bit banged GPIO.

Negative ports were never valid before (they fell through the upper bound
checks into a negative array index), so no existing meaning changes.

The implementation lives in soft_i2c.inl, a code fragment that a platform
implementation includes inside its i2c namespace, so every function gets
internal linkage there: the only public contract is the negative port
number itself. The esp32 implementation consumes it here, delegating at
the top of each public function. Platform specifics enter through macro
hooks: line driving (esp32 toggles the open drain latches its pinMode()
provides, the default switches the pin direction), locking (a FreeRTOS
mutex here, single threaded by default) and the yield used while a
stretched clock is waited out. Clock stretching is honored wherever SCL
is released, and the transaction semantics mirror the peripheral path: an
unacknowledged address surfaces at the next operation or at
endTransaction, not at beginTransaction.

Verified on hardware against the same physical bus: the scan results and
register reads of both software slots are identical to the normal port
and the low power port, and an out of range slot is rejected at init.
A negative host selects a software bus driven on the pins directly,
without claiming an SPI peripheral: host -1 is slot 0, host -2 is slot 1.
The convention matches the touch driver configs, where a negative host
has always meant bit banged GPIO.

Like the software I2C, the implementation is an internal fragment
(soft_spi.inl) included inside the spi namespace of a platform
implementation, and the esp32 implementation consumes it here by
delegating at the top of each public function. Only the portable pin
functions are used, so the fragment is platform independent.

Verified with a loopback on hardware: every SPI mode passes at throttled
and unthrottled speeds, both through a single open drain pin and through
a jumper between two pins, and an out of range host is rejected at init.

The quad SPI init refuses a negative host: the software slots are
single bit only.
The autodetect probes now go over the software I2C port, so nothing on
the hardware side is claimed or reconfigured until a board is confirmed;
a candidate that does not match leaves no trace beyond the pins, which
the existing pin backups already restore. Once a board is confirmed, the
bus is handed over to the hardware port that its backlight and touch use
at run time, preserving the open-port state those consumers rely on.

_detect_i2c_device() carried its own bit banged address probe; it now
rides on the same software port. This also removes the ACK contention it
had (the probe drove the lines push-pull, so a device pulling the
acknowledge low fought the driver), and the pull-up plausibility check it
performs beforehand stays as it was.

The paths where no board matched did not release the hardware port on
the C6 and P4 sections; with the probe on the software port there is no
hardware claim to leak, and the software slot is returned explicitly.

Verified on hardware for the three affected chips (C5 / C6 / P4 boards):
detection, display, backlight and touch all work, and the internal I2C
scan results are byte for byte identical to builds without this change.
The config carried the port in an unsigned byte, so a software port
could not be named there. Store it signed, the way the touch configs
already do, and skip the peripheral busy checks for a software port,
whose transfers are synchronous. The values in actual use (0 and 1)
read the same either way.
Low power I2C support and software (bit-banged) I2C / SPI on negative port numbers
Both functions picked the register block with #if I2C_NUM_MAX > 1, but
I2C_NUM_MAX is an enum constant, not a macro: the preprocessor evaluates
the condition as 0 > 1, the I2C1 branch never compiled, and every port
was polled as I2C0 (verified on ESP-IDF 4.4 and 5.x). On a second
hardware port, and on the low power ports now that they are reachable,
the functions report an idle bus while a transfer is still on the wire.

Resolve the device through the i2c implementation instead, which already
knows every port kind, as new i2c::wait() / i2c::busy() entry points; a
software port reports not busy there, since its transfers are
synchronous. The bus busy bit moves into a getBusBusy() accessor next to
the other per-target register accessors, replacing both the duplicated
target lists in beginTransaction() and the member detection templates
that Bus_I2C.cpp carried for the same purpose.
Poll the port the transfer is on in Bus_I2C::wait() and busy()
The port arguments were bounds checked against I2C_NUM_MAX, which counts
every port the ESP-IDF headers know of. On an ESP-IDF recent enough to
number the low power port but older than the 5.4 that the low power
support here requires, that bound accepts the low power port number
while getDev() has no low power branch and folds it onto a high power
port: the functions silently drive that peripheral instead, alongside
its legitimate user and without its lock.

Check against the ports this implementation can actually drive instead
(the high power ports, plus the low power ones when compiled in), so an
unsupported low power port number fails with invalid_arg. On the
configurations where the support is present, and on the ESP-IDF versions
that predate the low power port entirely, both bounds are equal and
nothing changes.
Refuse a low power I2C port number when the low power support is absent
The consecutive reads exist to reject a frame whose bytes changed
mid-read, but while a finger is moving no two reads agree: the retries
run out and the function reported no touch at all, so drags and flicks
kept breaking up mid-gesture. Adopt the most recent reading instead,
the way Touch_FT5x06 already does. A frame whose coordinate payload
could not be read is dropped rather than adopted, so a torn read does
not turn into a touch at (0,0).
On the GDMA chips (C3 / S3 / C6) the OUT_LINK register carries the
descriptor address bits [19:0] together with the START bit, and
Bus_SPI ORs the address into its START writes. The AHB_DMA generation
dropped the address bits from OUT_LINK and reads them from a separate
OUT_LINK_ADDR_CHn register: the OR is silently ignored, the DMA starts
with no descriptor, and the first DMA transfer hangs waiting for an
OUT FIFO that never fills.

The P4 (AXI_DMA) already needed a split address register; reuse that
two-register path on the C5 and C61, whose OUT_LINK_ADDR_CHn registers
sit 4 bytes apart. The H4 carries an AHB_DMA too but lays these
registers out inside each channel block, so it keeps the old path.
Verified on an ESP32-C5.
The internal bus (SDA=G2 / SCL=G3) sits exactly on the C5's fixed
LP_I2C pads. Move the backlight, the touch panel and the
post-detection bus onto the low power port; Port A is a level-shifted
branch of the same bus, so this leaves the C5's single high power
I2C controller entirely free. On Arduino builds this also takes the
internal devices off TwoWire, since the low power port is driven by
the ESP-IDF driver directly.

The port choice follows the exact condition the common I2C code
compiles its low power support under (ESP-IDF 5.4 with the new
driver); anything older stays on the high power port.

Pairs with the M5Unified change that moves In_I2C to the same port.
The same ILI9342 panel already runs at this rate on the Core2 and
Tough; verified on the actual board.
ToughC5 fixes: SPI DMA on AHB_DMA, internal I2C on the low power port, touch retries
…lized

A pin routed to the low power I2C keeps that routing across a reset,
because it is held in the low power domain rather than by the CPU.
set_pin() already hands such pins back before taking them for a hardware
port, but the software port path did not: the bit-banged lines stayed
bound to the low power peripheral and never reached the pads, so the
board autodetect probe (which runs on a software port) failed on every
warm boot until a power cycle reset the routing.

Release the pads in init() for a software port as well, the same way
the hardware port path does.
ainyan03 and others added 27 commits August 13, 2026 07:30
ESP-IDF v6 drops the ESP32-classic HSPI_HOST/VSPI_HOST aliases, which
broke the autodetect path and the Atom/Module display headers when
building against it. SPI2_HOST/SPI3_HOST are the canonical names since
IDF v4, so this stays compatible with every Arduino core and IDF
version the CI floor covers.
- The IDF matrix gains esp32c5 (5.5.2), esp32c6 on 5.5.2 so the low
  power peripheral paths compile, and esp32 on 6.0.2 to surface next
  major deprecations early.
- The Arduino matrix gains esp32c5 on arduino-esp32 3.3.11 (C5 support
  landed in the 3.3 series).
- Both workflows now cancel superseded runs of the same branch or pull
  request, keyed by event name and PR number.
Fix IDF v6 build (HSPI/VSPI aliases) and extend the CI matrix
The backlight was previously on/off only, so setBrightness had no
dimming effect. Drive it with the IO expander PWM channel in the same
way as the ChainCaptain, with a gamma 2.0 curve so the perceived
brightness follows the setting.
Add PWM brightness control for the M5ToughC5 backlight
ESP-IDF v6 spi_bus_initialize() routes bus pins with gpio_matrix_output()
and no longer clears the pad open-drain flag (v5 used gpio_set_direction(),
which did). If the pads were previously configured as open-drain outputs
(e.g. by the autodetect pull-up probing of the bus pins), SCLK/MOSI stay
open-drain, the waveform cannot rise at MHz clock rates through the weak
pull-up, and the panel never responds.

Explicitly restore the bus pins to push-pull after bus initialization.

Verified on M5Stack CoreS3SE: ESP-IDF v6.0.1 autodetect and draw/readRect
round-trip now pass; no regression on ESP-IDF v5.5.4 (CoreS3SE) or
M5StickS3 (v6.0.1).
…s in pinMode

The pad_driver and func_out_sel register layouts are named differently on
some chips (e.g. ESP32-C61), which required chip-specific #ifdef branches
and SFINAE helpers. Delegate to gpio_ll_od_enable/od_disable and
esp_rom_gpio_connect_out_signal, whose per-chip implementations absorb
the naming differences, so future chips need no new branches here.
Direct register access remains only as a fallback for ESP-IDF v3.

esp_rom_gpio_connect_out_signal also normalizes the inv/oen matrix bits,
which matches the intended plain-GPIO-output contract of pinMode.

Build-verified on esp32s3 (ESP-IDF v5.5.4 / v6.0.1), esp32c61, esp32
(v5.5.4), and Arduino-ESP32 2.x (ESP-IDF v4.4); runtime-verified on
M5Stack CoreS3SE with ESP-IDF v6.0.1.
Fix CoreS3 LCD not working on ESP-IDF v6 (SPI bus pins left in open-drain)
The fix for SPI bus pins left in open-drain mode on ESP-IDF v6 (#192)
covered only the single-bit spi::init path. spi::initQuad (used by QSPI
panels) calls spi_bus_initialize the same way and is affected by the
same behavior: ESP-IDF v6 routes bus pins with gpio_matrix_output() and
no longer clears the pad open-drain flag.

Factor the pad restoration out of spi::init into a shared helper and
apply it to SCLK/IO0-3 in spi::initQuad as well.

Verified on M5StopWatch (CO5300, QSPI): with the bus pins deliberately
set to open-drain before init, the pads remained open-drain after init
on ESP-IDF v6.0.1 without this fix, and are restored to push-pull with
it. No regression on ESP-IDF v5.5.4.
The LEDC pin attach does not fully normalize the pad state: the pad
open-drain flag is never cleared (on any IDF version), and starting
with ESP-IDF v6 the ledc driver no longer selects the GPIO function in
IO_MUX either. If the backlight pin was previously configured as an
input or open-drain output (e.g. by probing code or a previous
application state), the PWM output cannot drive the pin high and the
backlight stays off.

Explicitly normalize the pin as a push-pull GPIO output at the start of
init. The pin is first latched to the "off" level to avoid a visible
glitch while the pin is temporarily a plain GPIO output.

Verified on M5Stack Core FIRE (backlight on GPIO32) with ESP-IDF
v6.0.1: with the pin deliberately set to open-drain before init, the
pad remained open-drain after init without this fix, and is restored
to push-pull with it.
Neither esp_lcd_new_i80_bus (ESP32-S3, since ESP-IDF v5.4 removed its
gpio_set_direction call) nor the plain GPIO matrix routing used on
ESP32-S2 configures the pad direction or clears the open-drain flag on
the data pins. If a data pin was previously configured as an input or
open-drain output, the bus cannot drive it high and the panel receives
corrupted data. Bus_EPD already guards against this by calling
pinMode(output) on its data pins; apply the same guard to
Bus_Parallel8/16. The WR/RD/RS control pins were already normalized
via gpio_set_direction.

Verified on ESP32-S3 (ESP-IDF v6.0.1) by pulse-counting bus edges with
PCNT on a scope-free rig: with the data pins deliberately set to
open-drain before init, a 0x00/0xFF pattern produced 0 edges on
D0/D7 (WR counting normally) without this fix, and the expected edge
count with it.
Light_PWM::init ignored the result of the LEDC configuration calls and
always returned true. Propagate failures instead: init now fails early
when the configured pin cannot be driven as a GPIO output, the ESP-IDF
path checks both ledc_channel_config and ledc_timer_config, the
Arduino 3.x path returns the ledcAttach result, and the pre-3.x path
checks the frequency returned by ledcSetup (0 on failure) and only
attaches the pin when setup succeeded. On failure the initial
setBrightness call is skipped.

The caller (Panel_Device::init) ignores the return value, so panel
initialization is unaffected; the result is now meaningful for callers
that choose to check it.
Clear stale open-drain pad state in QSPI, parallel bus, and PWM backlight init
…PENDENT removed)

ESP-IDF v6 removed the SOC_GPIO_SUPPORT_RTC_INDEPENDENT soc capability
macro. pinMode treated the missing macro as 0, which routes RTC-capable
pins through the RTC-domain pull path meant for the plain ESP32. On
ESP32-S2/S3 (and other chips with independent digital pads) that path
does not drive the pad pulls: input_pullup left the IO_MUX FUN_PU bit
clear and the line did not rise.

Map the capability to an internal LGFX_GPIO_RTC_INDEPENDENT macro that
uses the SoC value when available and otherwise derives it from the
build target: only the plain ESP32 needs the RTC path.

Verified on ESP32-S3 (floating RTC-capable pin, ESP-IDF v6.0.1):
input_pullup previously read back low with FUN_PU clear; with this fix
the pull-up is driven and the pin reads high. No regression on v5.5.4
(macro still provided by the SoC caps) and no behavior change for the
plain ESP32.
Fix pinMode pulls not driven on ESP-IDF v6 (SOC_GPIO_SUPPORT_RTC_INDEPENDENT removed)
Panel_CO5300 supports direct drawing: the optional PSRAM frame buffer
(initPanelFb) may fail to allocate and is not required, so the PSRAM
guard (introduced alongside the EPD boards, which genuinely need the
buffer) is unnecessary here. Worse, with PSRAM disabled the guard
skipped the panel setup after the board had already been identified,
and the autodetection then fell through and misidentified the device
as an AtomS3Lite.

Direct drawing has a limitation: drawing whose origin is at an odd
coordinate may render incorrectly, so a warning is logged when the
build lacks OPI-PSRAM.

Verified on the real device: with PSRAM disabled the board is now
detected as M5StopWatch and the display works via direct drawing;
builds with and without OPI-PSRAM are both green.
When PSRAM was disabled in the build, the EPD board blocks (PaperMono,
PaperS3/PaperDIY, PaperColor, ChainCaptain) logged the requirement but
skipped the panel setup and fell through the rest of the autodetection,
which ended with board_unknown and let the caller misidentify the
device as a display-less model. Jump to init_clear instead so the board
identification is kept; the display simply stays unavailable.

Clear _panel_last and _touch_last before the jump: autodetect() only
detaches the active panel at entry, so a stale panel object from a
previous detection round would otherwise be re-attached at init_clear.
Fix board misidentification when OPI-PSRAM is disabled
The ST71xx touch controller needs several tens of milliseconds to
respond again when it is reset during scan operation. The previous
3-attempt probe gave up too early, so a warm reset from a running
state (esp_restart or the reset caused by connecting a serial
monitor) left the panel type undetected and the screen blank.
Poll until a recognized ST71xx firmware version is read or the
GT911 (ILI9881C variant) ACKs, up to roughly 600 ms. The ST variants
still require the touch FW version to distinguish their DSI timings;
a unit whose touch never responds falls back to the previous
behavior (the ILI9881C variant is still identified by its DSI ID).
Fix M5Tab5 black screen after a warm reset (wait for the touch controller response)
Some VLW exporters (e.g. the Processing font converter) omit the space
glyph from the file. Falling through to drawCharDummy would render the
missing-glyph box for every space, so restore the previous silent
guessed-width behavior for that case only. Fonts that do contain a
space glyph keep using the actual glyph metrics.
Use actual glyph width, not the guessed spaceWidth, to determine the width of the space character in VLW fonts
Some GC9107 batches return a valid ID over the RDDID command only at
low clock rates; at the 8 MHz probe speed the readout is garbage, so
autodetect rejected the panel and the unit appeared dead (issue #222).
When the first probe matches neither known ID, re-probe once at 100 kHz.

Verified on a healthy GC9107 unit that the 100 kHz readout returns the
correct ID (0x079100), and on both GC9107 and ST7735 units that the
normal first-probe path is unchanged. The ST7735 does not answer the
RDDID readout at low clock rates at all (verified: 0xFFFFFFFF at 1 MHz
and below, correct at 8 MHz), so the slow retry cannot misidentify it;
a healthy ST7735 has already been caught by the first probe.
AtomS3R: re-probe the panel ID at low clock speed before giving up
Update the version define missed in the 0.2.27 bump
@lovyan03
lovyan03 merged commit 93b480b into master Aug 17, 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.

9 participants