Skip to content

Low power I2C support and software (bit-banged) I2C / SPI on negative port numbers - #235

Merged
lovyan03 merged 6 commits into
m5stack:developfrom
ainyan03:soft_i2c
Aug 1, 2026
Merged

Low power I2C support and software (bit-banged) I2C / SPI on negative port numbers#235
lovyan03 merged 6 commits into
m5stack:developfrom
ainyan03:soft_i2c

Conversation

@ainyan03

@ainyan03 ainyan03 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

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_host already 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_I2C configs 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

  • LP + software I2C: on C5 / C6 / P4 boards, a scan on each path (normal port / LP port / soft slot -1 / soft slot -2) over the same physical bus finds the same devices, and register reads return identical data.
  • Software SPI: hardware loopback through a single open-drain pin and through a pin-to-pin jumper, all four SPI modes, throttled and unthrottled clocks.
  • Autodetect: on the three affected chips, detection / display / backlight / touch all work, and the internal I2C scan output is byte-for-byte identical to builds without this change.
  • Out-of-range slots are rejected at init.

Per-commit details are in the commit messages.

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.
@lovyan03
lovyan03 requested a review from Copilot August 1, 2026 02:01
@lovyan03
lovyan03 merged commit 68206c3 into m5stack:develop Aug 1, 2026
23 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.cpp to 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.

Comment on lines 120 to 124
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;
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