extmod/machine_i2c: Add I2C.scl() and I2C.sda() methods. by sebromero · Pull Request #19672 · micropython/micropython · GitHub
Skip to content

extmod/machine_i2c: Add I2C.scl() and I2C.sda() methods. - #19672

Open
sebromero wants to merge 8 commits into
micropython:masterfrom
sebromero:i2c-pins
Open

sebromero wants to merge 8 commits into
micropython:masterfrom
sebromero:i2c-pins

Conversation

@sebromero

@sebromero sebromero commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

This adds I2C.scl() and I2C.sda(), which return the machine.Pin objects a bus is using.

The motivating use case is I2C bus recovery. In the Arduino Modulino package, Modulino nodes have their own MCU, so resetting the host board or hot-swapping a node mid-transaction can leave the bus stuck with a peripheral holding SDA low. Recovering from that means driving SCL for up to nine cycles while SDA is held high, then re-initialising the bus object. To do this the code has to know which pins the bus is on, and there is currently no API that answers that question. The library just receives an I2C object. This way we don't have to ask the library users to explicitly provide (and know) the pins used for the I2C bus. (The target audience is beginners).

The workaround in use today is to take repr() of the I2C object and pull the pin names out with a regular expression. That is expensive (it formats and allocates a string on a code path that runs at startup), and it depends on the exact layout of a debug representation that is port-specific and not an API contract.

The methods are backed by two new optional entries in the mp_machine_i2c_p_t
protocol struct:

  • machine.SoftI2C implements them on all ports.
  • Hardware I2C implements them on esp32, renesas-ra, rp2, samd and stm32.
  • Hardware I2C on the remaining ports returns None, which is documented.

To keep the shared code portable this also adds mp_hal_pin_to_obj() to the C-level
pin HAL of every port that builds extmod/machine_i2c.c with SoftI2C enabled. See
Trade-offs and Alternatives for why that is needed.

docs/library/machine.I2C.rst documents both methods, including the None case and
which ports report hardware pins.

Testing

A new hardware test, tests/extmod_hardware/machine_i2c_pins.py, checks that both
SoftI2C and hardware I2C report their pins, that the reported pins follow a subsequent
init(), and that a port which does not report hardware pins returns None
consistently for both. It needs no external wiring, and selects pins per target the
same way extmod_hardware/machine_i2c_target.py does.

Tested on hardware, 3/3 tests passing on each, covering all three ways a port can
represent a pin:

Board Port mp_hal_pin_obj_t
Arduino Nano RP2040 Connect rp2 uint (GPIO number)
Arduino Nano ESP32 esp32 (S3) gpio_num_t
Arduino Nicla Vision stm32 (H747) const machine_pin_obj_t *
Arduino Portenta C33 renesas-ra (RA6M5) const machine_pin_obj_t *

Beyond the test, the returned objects were checked to be the same canonical Pin
objects reachable by name on each board - e.g. on the Nano RP2040 Connect,
I2C(1).scl() is Pin('A1'), and on the Nicla Vision the hardware buses report
Pin(Pin.cpu.B8, mode=ALT_OPEN_DRAIN, pull=PULL_UP, alt=AF4_I2C1) and friends for
I2C1/2/3, matching the board definition.

Build-tested only: samd (ADAFRUIT_ITSYBITSY_M4_EXPRESS) and unix.

Not tested at all, and where I would appreciate CI or another contributor's boards:
esp8266, nrf, mimxrt, alif, zephyr, psoc-edge. These receive only the one-line
mp_hal_pin_to_obj addition. For five of them it is the same pointer cast that stm32
and renesas-ra use, both of which are hardware-tested. esp8266 is the one that
differs materially
- it indexes the port's pin table rather than casting a pointer -
and I have no toolchain for it here, so it is unverified. nrf could not be built
locally either (its build needs SoftDevice headers that make submodules does not
fetch).

Trade-offs and Alternatives

Code size. Measured on PYBV11, the whole series costs +104 bytes of text, with
no change to data or bss (376360 -> 376464). Per-symbol on that build: the two shared
dispatchers in extmod are 20 bytes each, the two stm32 accessors are 4 bytes each
(the HAL conversion is a cast, so the compiler emits almost nothing), the two function
objects are 8 bytes each, plus two locals-dict entries and two protocol-struct slots.
Ports with integer pin types pay ~32 bytes more for the mp_hal_pin_to_obj lookup
function. Every port pays for the two dict entries even if its hardware I2C returns
None; that could be avoided with a config option, but a two-entry gate did not seem
worth the mpconfig surface.

Alternative considered and rejected: standardising repr() instead. Since the
existing workaround parses repr(), one option is to make that output complete and
uniform across ports rather than add an API. Rejected for three reasons. First,
repr() is a debug representation, not an API contract - code that parses it relies on
something no port promises to keep stable, and changing the output to standardise it
would itself break anyone already parsing it. Second, it is not merely inconsistent, it
is absent: mimxrt prints I2C(%u, freq=%u, timeout=%u) and zephyr prints
<I2C %s>, neither of which contains the pins - and neither port stores the pins in
its I2C object, so completing their repr() needs exactly the same underlying change
as implementing the accessors. Third, where pins are printed the formats differ
(integers on esp32/rp2, quoted names on stm32/renesas-ra/alif/samd), so a parser still
needs per-port handling, and it pays string formatting, allocation and regex matching
at runtime for information the object already holds.

API shape: two methods, rather than one config()/dict getter or attributes.
Per-parameter accessor methods are what machine peripheral classes already use -
PWM.freq(), PWM.duty_u16(), PWM.duty_ns() - whereas the config('param') form
belongs to the network and Bluetooth classes (WLAN.config(), BLE.config()). Two
methods therefore follow the convention for this class, reuse the existing scl/sda
QSTRs so they add no new strings, and return the pin objects without allocating; a dict
would build and allocate one on every call, on a path that may run precisely when a bus
is already wedged.

The counter-argument is real, though: constructors take more than these two parameters
(id, freq, timeout), and a general getter would scale to all of them where adding
one method per parameter does not. I think that is worth doing as its own discussion
rather than here, because it is a cross-class decision - SPI, UART and PWM would
all want the same shape - and because scl/sda are the parameters with a concrete
use case today. If maintainers would rather have config() on machine classes, I am
happy to rework this on top of that decision. Either way the API shape set here is a
precedent for the other bus classes, so it is worth settling explicitly.

Why not store mp_obj_t in the SoftI2C object and avoid the conversion entirely?
mp_machine_soft_i2c_obj_t stores mp_hal_pin_obj_t because the bit-banging code
calls mp_hal_pin_od_low(), mp_hal_pin_read() and friends on it once per bit.
Storing the Python object would move a conversion into that hot path, change a struct that is public to every
port's soft-I2C code, and add GC-visible references to a struct that has none today.

Why is mp_hal_pin_to_obj a macro on some ports and a function on others? It
follows what mp_hal_pin_obj_t actually is, and matches how each port already defines
mp_hal_get_pin_obj in the same header:

  • Where a pin is already a pointer to the pin object (stm32, renesas-ra, nrf, mimxrt,
    alif, zephyr), the conversion is a pure cast - MP_OBJ_FROM_PTR - so a macro emits
    no code at all.
  • Where a pin is an integer (esp32, rp2, samd), it needs a bounds-checked table lookup
    that raises on failure. That cannot go in the header: the tables and helpers are
    declared in machine_pin.h / pin_af.h, which mphalport.h does not include, and
    mphalport.h is pulled in extremely early by nearly every file - adding those
    includes invites include cycles. The body also raises, which would drag
    py/runtime.h into every consumer.
  • esp8266 is integer-typed but stays a macro: its pin table is already visible in
    esp_mphal.h, the table index equals phys_port for every valid entry, pin ids only
    ever arrive from the validating mp_obj_get_pin(), and that port is the most
    ROM-constrained in the tree.

Each port's helper therefore raises that port's own existing message for a bad pin
("invalid pin" on esp32/rp2, "not a Pin" on samd, via pin_find_by_id()) rather than a
new uniform one. In practice these paths are unreachable through this feature, since
stored pins always came from mp_hal_get_pin_obj() or a board's compile-time defaults;
the checks are for future callers of the HAL helper.

The cost of the mixed macro/function form is that macros get no type checking, and a
new port that omits the definition finds out only when something compiles
machine_i2c.c with SoftI2C enabled - a loud build error rather than silent breakage.
Making all ten real functions would be more uniform, at the price of adding a .c
definition to six ports that currently need no code; happy to change it if preferred.

Hardware I2C returning None on some ports. Filling the gap
is not uniform work: alif already stores its pins and would be the same two-liner, but
mimxrt and zephyr do not keep the pins in their I2C object (so it means adding fields or
recovering them from the peripheral/devicetree config), and nrf and psoc-edge have no
ports/*/machine_i2c.c at all. Since I cannot test any of those boards, I would rather
leave them to people who can than add unverified code. The alternative - raising
NotImplementedError instead of returning None - would make the gap louder but costs
an error message on every affected port; happy to switch if that is preferred.

Generative AI

I used generative AI tools when creating this PR, but a human has checked the
code and is responsible for the code and the description above.

This is the inverse of mp_hal_get_pin_obj(): it converts a low-level
mp_hal_pin_obj_t back into the corresponding machine.Pin object.  Ports
that store a pointer to their pin object get a simple cast, while ports
that store a pin index look the object up in their pin table.

It is added to all ports that build extmod/machine_i2c.c with SoftI2C
enabled, so that shared code does not have to know how a given port
represents a pin.

Signed-off-by: Sebastian Romero <s.romero@arduino.cc>
These return the machine.Pin objects that the bus is using, which makes
it possible for code to reuse the pins of an existing I2C instance
without having to be told what they are.

The methods are backed by two new optional entries in the I2C protocol
struct.  SoftI2C implements them on all ports via mp_hal_pin_to_obj();
hardware I2C implementations that do not provide them return None.

Signed-off-by: Sebastian Romero <s.romero@arduino.cc>
Report the SCL and SDA pins of a hardware I2C instance.

Signed-off-by: Sebastian Romero <s.romero@arduino.cc>
Report the SCL and SDA pins of a hardware I2C instance.

Signed-off-by: Sebastian Romero <s.romero@arduino.cc>
Report the SCL and SDA pins of a hardware I2C instance.

Signed-off-by: Sebastian Romero <s.romero@arduino.cc>
Report the SCL and SDA pins of a hardware I2C instance.

Signed-off-by: Sebastian Romero <s.romero@arduino.cc>
Report the SCL and SDA pins of a hardware I2C instance.

Signed-off-by: Sebastian Romero <s.romero@arduino.cc>
Tests that SoftI2C and hardware I2C report their SCL and SDA pins, and
that the reported pins follow a subsequent init().  Pins are selected
per target, following extmod_hardware/machine_i2c_target.py.

Signed-off-by: Sebastian Romero <s.romero@arduino.cc>
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.59%. Comparing base (f668077) to head (086c127).
⚠️ Report is 16 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #19672   +/-   ##
=======================================
  Coverage   98.58%   98.59%           
=======================================
  Files         182      182           
  Lines       23322    23335   +13     
  Branches        5        5           
=======================================
+ Hits        22993    23006   +13     
  Misses        328      328           
  Partials        1        1           
Flag Coverage Δ
unix-coverage-32bit 98.59% <ø> (+<0.01%) ⬆️
unix-coverage-64bit 98.52% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown

Code size report:

Reference:  tests/extmod/machine_mem_backup: Add slice read/write test. [8162451]
Comparison: tests/extmod_hardware: Add test for I2C SCL/SDA pin retrieval. [merge of 086c127]
  mpy-cross:    +0 +0.000% 
   bare-arm:    +0 +0.000% 
minimal x86:    +0 +0.000% 
   unix x64:    +0 +0.000% standard
      stm32:  +104 +0.026% PYBV10
      esp32:  +188 +0.010% ESP32_GENERIC[incl +48(data)]
     mimxrt:   +96 +0.024% TEENSY40
        rp2:  +184 +0.019% RPI_PICO_W
       samd:  +112 +0.040% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:    +0 +0.000% VIRT_RV32

@robert-hh

Copy link
Copy Markdown
Contributor

What stops you from just remembering the pins that you use for I2C in your Python code?

@sebromero

Copy link
Copy Markdown
Contributor Author

@robert-hh Because this is being used in a library. The end user just passes an I2C object to the library (on Arduino boards it defaults to I2C(0)) which handles everything else.

bus = I2C(0)
pixels = ModulinoPixels(bus)
pixels.set_all_rgb(0, 255, 0, 100)
pixels.show()

We would have to force the user to figure out the pins used on the QWIIC port(s) and then pass those to the library. Not exactly the user experience we're aiming for.

@Josverl

Josverl commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Regarding the Python API,

You left it undocumented, so that is an open item,
But before that, and if I read the implementation correct, you seem to add 2 methods to the classes.
I wonder why you have chosen to add 2 methods, rather than a single config method or dict.
More so as most constructors can take additional parameters over just these two
As you indicate that you found that the repr() output was lacking on some ports or boards, perhaps fixing that that is an alternative that should be considered, or at least mentioned (as rejected?) with the relevant ports.

@sebromero

sebromero commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@Josverl Thank you for your feedback. The API is documented in docs/library/machine.I2C.rst

On the API shape: machine classes already use per-parameter accessor methods (PWM.freq(), PWM.duty_u16(), PWM.duty_ns()), whereas config('param') is the network/BLE convention (WLAN.config(), BLE.config()). Two methods follow the existing convention for this class, reuse the existing scl/sda QSTRs, and cost +104 bytes of text on PYBV11 (the port accessors compile to 4 bytes each on stm32, since the conversion is a cast). A config()-style getter for machine peripherals is a fair idea, but it's a cross-class decision (SPI/UART/PWM would want the same) and seems better as its own discussion than as part of this PR.

On repr(): I should have been clearer, and I'll add it to the PR as a rejected alternative. It isn't only that regex-parsing is expensive — repr is a debug representation rather than an API contract, and it doesn't carry the information everywhere: mimxrt prints I2C(%u, freq=%u, timeout=%u) and zephyr prints <I2C %s>, neither of which includes the pins. Those two ports don't store the pins in the I2C object at all, so making repr complete there needs the same underlying change as implementing the accessors. Where pins are printed the formats differ too (integers on esp32/rp2, quoted names on stm32/renesas-ra/alif/samd), so a parser needs per-port handling.

@Josverl

Josverl commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

The API is documented in docs/library/machine.I2C.rst

Sorry , my mistake, I completely overlooked that :|

Your reasoning on the API makes sense to me.

@dpgeorge dpgeorge added ports Relates to multiple ports, or a new/proposed port extmod Relates to extmod/ directory in source labels Sep 1, 2026
@dpgeorge

dpgeorge commented Sep 1, 2026

Copy link
Copy Markdown
Member

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

extmod Relates to extmod/ directory in source ports Relates to multiple ports, or a new/proposed port

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants