Low power I2C support and software (bit-banged) I2C / SPI on negative port numbers - #235
Conversation
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.
There was a problem hiding this comment.
🟡 Not ready to approve
Bus_I2C::wait()/busy() still assumes non-negative ports map to I2C0/I2C1 and can mis-handle the new LP I2C port index, potentially polling the wrong peripheral or hanging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Adds ESP32-family bus-layer extensions to support (1) low-power I2C controllers as additional lgfx::i2c ports and (2) software (bit-banged) I2C/SPI selected via negative port/host numbers, and updates board autodetect to probe via software I2C to avoid claiming peripherals before the target board is confirmed.
Changes:
- Add platform-internal software I2C/SPI implementations (
soft_i2c.inl,soft_spi.inl) and route negative port/host calls to them on ESP32. - Add ESP32 low-power I2C (LP I2C) handling by deriving per-port properties (FIFO depth, clock source) and opening LP buses via ESP-IDF where required.
- Update autodetect in
M5GFX.cppto probe over software I2C, then hand off to the hardware I2C port after the board is confirmed.
File summaries
| File | Description |
|---|---|
| src/M5GFX.cpp | Switch autodetect probing to a software I2C port (-1) and release/hand off to hardware I2C once a board is confirmed. |
| src/lgfx/v1/platforms/soft_spi.inl | Introduce internal bit-banged SPI implementation keyed by negative host numbers. |
| src/lgfx/v1/platforms/soft_i2c.inl | Introduce internal bit-banged I2C implementation keyed by negative port numbers, with transaction semantics aligned to existing paths. |
| src/lgfx/v1/platforms/sdl/Bus_I2C.hpp | Make i2c_port signed to allow negative software-port selection in configs. |
| src/lgfx/v1/platforms/esp32/common.cpp | Route negative SPI/I2C to software backends; add LP I2C support (port classification, source clock, FIFO sizing, driver-based init/release). |
| src/lgfx/v1/platforms/esp32/Bus_I2C.hpp | Make i2c_port signed and document negative=software in the ESP32 bus config. |
| src/lgfx/v1/platforms/esp32/Bus_I2C.cpp | Treat negative (software) I2C as synchronous in wait()/busy() (but needs LP-port handling adjustments). |
Review details
Suppressed comments (1)
src/lgfx/v1/platforms/esp32/Bus_I2C.cpp:137
- Bus_I2C::busy() has the same I2C0/I2C1 assumption as wait(). When _cfg.i2c_port refers to an LP I2C port (numbered after HP ports), this checks the wrong device’s busy bit (or a non-existent unit), which can make higher layers mis-handle transfer pacing.
bool Bus_I2C::busy(void) const
{
if (_cfg.i2c_port < 0) { return false; } // a software port transfers synchronously
#if I2C_NUM_MAX > 1
auto dev = (_cfg.i2c_port == 0) ? &I2C0 : &I2C1;
#else
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| void Bus_I2C::wait(void) | ||
| { | ||
| if (_cfg.i2c_port < 0) { return; } // a software port transfers synchronously | ||
| #if I2C_NUM_MAX > 1 | ||
| auto dev = (_cfg.i2c_port == 0) ? &I2C0 : &I2C1; |
What this adds
Two bus-layer extensions for the ESP32 family, plus their use in autodetect:
Low power I2C (ESP32-C5 / C6 / P4) — the LP I2C controller becomes reachable through the ordinary
lgfx::i2c::API as the port right after the normal ones. Bring-up goes through the ESP-IDF master bus driver (available on ESP-IDF 5.4 or later); the per-port assumptions the code used to hard-code (port count, FIFO depth, clock source) are now derived from the SoC descriptors.Software I2C / SPI on negative port numbers — a negative port/host selects a GPIO bit-banged bus: port -1 is slot 0, port -2 is slot 1. This matches the existing touch-config convention where a negative
spi_hostalready means bit-banged GPIO, and negative values were never valid before, so no existing meaning changes. The implementation lives in internal fragments (soft_i2c.inl/soft_spi.inl) included inside the platform namespaces, so the only public contract is the negative number itself.Bus_I2Cconfigs accept a negative port as well.Autodetect probing — board probes now run over the software I2C port, so nothing on the hardware side is claimed or reconfigured until a board is confirmed; the bus is then handed over to the hardware port that the board's backlight and touch use at run time.
Verification
Per-commit details are in the commit messages.