Skip to content

Add machine.I2CTargetMemory implementing a simple I2C memory device #17365

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from

Conversation

dpgeorge
Copy link
Member

Summary

This PR implements a simple I2C target/peripheral/"slave" device that allows reading/writing a specific region of memory (or "registers") on the target I2C device.

The class is called machine.I2CTargetMemory. It's very simple:

from machine import I2CTargetMemory

mem = bytearray(8)
i2c = I2CTargetMemory(addr=67, mem=mem)

That's all that's needed to start the I2C target. From then on it will respond to any I2C controller on the bus, allowing reads and writes to the mem bytearray.

This is based on the discussion in #3935.

An implementation is provided for rp2 (which has a very clean i2c-slave interface in pico-sdk) and stm32.

Testing

A test is added that has been tested and passes on RPI_PICO2_W and PYBD_SF6.

Trade-offs and Alternatives

This is a very simple implementation, but it works and is probably enough for most use cases. There are lots of things that could be enhanced:

  • add Python callbacks to notify when the I2C controller reads/writes memory
  • implement 16-bit wide memory addressing (currently restricted to 8-bit addresses)
  • implement other I2C targets, eg FIFO, or generic
  • add support for asyncio, eg polling the device for events

dpgeorge added 6 commits May 27, 2025 14:02
Signed-off-by: Damien George <damien@micropython.org>
And add MP_STATIC_ASSERT to statically check that the IRQ names are correct
on the MCU that it's compiled for.

Signed-off-by: Damien George <damien@micropython.org>
Works, tested on PYBD_SF6:

    buf = bytearray(16)
    machine.I2CTargetMemory("X", 67, buf)

Signed-off-by: Damien George <damien@micropython.org>
Signed-off-by: Damien George <damien@micropython.org>
Signed-off-by: Damien George <damien@micropython.org>
Signed-off-by: Damien George <damien@micropython.org>
@dpgeorge dpgeorge added the extmod Relates to extmod/ directory in source label May 27, 2025
@dpgeorge
Copy link
Member Author

I2CTargetMemory does seem like a bit of an unusual name, but "I2C target" is officially what an "I2C slave" is called now. And in the future we may have a machine.I2CTarget class which implements a generic target, so machine.I2CTargetMemory seems like the only option for naming.

I guess it could be shortened to machine.I2CMemory...

Copy link

codecov bot commented May 27, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 98.54%. Comparing base (2dada06) to head (9f329b0).

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #17365   +/-   ##
=======================================
  Coverage   98.54%   98.54%           
=======================================
  Files         169      169           
  Lines       21898    21898           
=======================================
  Hits        21579    21579           
  Misses        319      319           

☔ View full report in Codecov by Sentry.
📢 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.

@chrismas9
Copy link
Contributor

Interesting timing. I have an immediate need for this. I have just written an implementation of #3935 in MicroPython. It implements callbacks and 16-bit addressing, but needs to be called regularly in the main loop to service I2C requests from the controller. It has some other limitations, but is enough for my applications.

I see this failed on STM32F091. I am using STM32L432. I will try it and report back.

Callbacks would be useful, but I can live without them. My inputs need to be debounced or filtered and that logic can update the read registers. For write registers I will maintain a backup copy and diff them periodically to update outputs.

Would the memory_read callback be called before the I2C reply so that the memory can be updated on demand, eg from a pin, ADC, etc?

Is it necessary to name it other than I2CTarget ? If it were named I2CTarget now, with maybe mode=memory it would allow applications to keep working after a full featured I2CTarget was added to MicroPython.

@chrismas9
Copy link
Contributor

I can build for PYBV10, but not for NUCLEO_L476RG or my L432. I will investigate tomorrow.

@kwagyeman
Copy link
Contributor

@dpgeorge - Alif support? :)

@robert-hh
Copy link
Contributor

That is a great start for the slave modes of I2C and SPI. I wondered why you added a separate class instead of extending the I2C class with more options, like mode and addr. But this was the implementation is simpler.
I agree with @chrismas9 that some kind of signalling mechanism should exists, telling that a I2C transaction has been finished. That can happen when machine_i2c_target_data_reset() is called, whether by a callback or just a flag that can be polled.

#define IS_VALID_SDA(i2c, pin) (((pin) & 1) == 0 && (((pin) & 2) >> 1) == (i2c))

static machine_i2c_target_data_t i2c_target_data[4];

Copy link
Contributor

Choose a reason for hiding this comment

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

It seems that the code only uses 2 of these data objects.

};

MP_REGISTER_ROOT_POINTER(mp_obj_t pyb_i2cslave_mem[4]);

Copy link
Contributor

Choose a reason for hiding this comment

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

4 vs 2.

@robert-hh
Copy link
Contributor

I could start to implement this feature with the MIMXRT port. The NXP lib has support for the slave mode.

@chrismas9
Copy link
Contributor

I would prefer callbacks to a flag as they allow for a much faster response for I2C reads if the register is updated in the callback. How would a flag work? Would the read function block until the flag is polled, the register updated and the flag is cleared, or would it immediately reply with stale data? The other problem, which I have with my python library, is that the Controller timeout has to be larger than the slowest poll time. I have use cases where such latency would be a problem, eg measuring motor current in a servo controller

@robert-hh
Copy link
Contributor

A flag would just tell the status of the read/write process and it's state would be returned by a non-blocking call. The number of states is t.b.d., like BUSY, DONE_READING, DONE_WRITING. A callback is faster and more flexible, but would need to carry similar information about what happened.


static void mp_machine_i2c_target_memory_deinit(machine_i2c_target_memory_obj_t *self) {
i2c_slave_deinit(self->i2c_inst);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Should not the memory object be released here as well?

@chrismas9
Copy link
Contributor

I have added all STM32 families. I will submit the changes after testing them all on Eval boards.

@chrismas9
Copy link
Contributor

I was hoping for a blocking read callback that could update a register with fresh data before the reply using clock stretching during the callback. I am making a series of modules which should behave like standard ICs. Most ADCs respond to a read with fresh data, either by clocking a SAR with SCL or using clock stretching until a conversion is finished. Any background loop that has to continuously update registers will result in reading data that is probably 10 to 100ms out of date. I'm not sure it's very useful to have a read callback after the event.

Write callbacks after a write are very useful as long as they contain the range of registers written.

@robert-hh
Copy link
Contributor

robert-hh commented May 29, 2025

There is an initial version for MIMXRT now at https://github.com/robert-hh/micropython/tree/mimxrt_i2c_target. Needs more testing.

@dpgeorge
Copy link
Member Author

@robert-hh I've added a test to this PR which you should be able to run on an mimxrt board, to validate your implementation.

Note: the API here will most likely change based on feedback.

@robert-hh
Copy link
Contributor

Thanks. I have seen the test and will run it. And yes, I expect the API to change, but that can be adapted easily.

typedef struct _machine_i2c_target_data_t {
uint8_t first_rx;
uint8_t addr;
uint8_t len; // stored as len-1 so it can represent up to 256
Copy link
Contributor

Choose a reason for hiding this comment

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

len could as well be uint16_t. The first 3 items would still need <= 4 bytes. That would still not increase the size of machine_i2c_target_data_t but would not need the extra +/-1 calculations for the length..

@robert-hh
Copy link
Contributor

There were some issues in the wrap tests, that are fixed now. After adapting machine_i2c_target_memory.py with the settings for this board, all tests pass. The code can still be optimized somewhat and tested at the various board types, especially the slower i.mx1011 variants.

@robert-hh
Copy link
Contributor

@dpgeorge Which wiring would you use for the automated test with the Teensy 4.1. I2C(0) as hardware I2C for the targeth, but which GPIO Pins for the controller port?

@dpgeorge
Copy link
Member Author

I was hoping for a blocking read callback that could update a register with fresh data before the reply using clock stretching during the callback.

This is an important question to discuss further. With a read transaction (point of view from the controller) we have:

  1. generate start condition
  2. send address byte, with direction flag (read)
  3. read byte from the target, so the target must drive SDA
  4. generate stop condition

If the target is not ready in step 3 to send the data (eg an ADC that needs to be sampled) then the target could stretch the clock by holding SCL low. Then release it when it has the new data ready.

If we want to support such clock stretching then the API and implementation will be quite different, because we need a way for the Python code to signal that it wants to stretch the clock (stall the bus), and then indicate when it is ready to unstall the bus.

There are real devices out there that do this clock stretching, eg ADS7128 (and it seems that device stretches SCL for quite some time when it's averaging many readings). Other devices like ADS1115 require you to poll a register to see when the ADC conversion is ready. And something like MCP3021 will do the ADC conversion during the first few bits of the data read (and those bits always read 0) so don't need to stretch SCL.

Looking at Zephyr's I2C target API, it does not support clock stretching, so it's not possible to implement a clock stretching target with Zephyr.

Do we need the MicroPython I2C target API to be able to do clock stretching?

Other considerations:

  • clock stretching for writes to the target; this could be used for example to implement flow control when a lot of data is written out to the target (it holds SCL low if it's not ready to receive more data)
  • support atomic reads from memory device, eg if the I2C controller is reading a 16-bit value from the I2C target, you don't want the I2C target to update that memory in the middle of the read or else the upper and lower bytes of the 16-bit value will be out of sync
  • support atomic writes

Things can get complicated pretty quickly when looking at all these things.

@dpgeorge
Copy link
Member Author

Which wiring would you use for the automated test with the Teensy 4.1. I2C(0) as hardware I2C for the targeth, but which GPIO Pins for the controller port?

I'd use SoftI2C for the controller side, so it doesn't matter which pins. Teensy already needs D0/D1 and D2/D3 and D11/D12 connected for tests/extmod_hardware/machine_pwm.py, so maybe can reuse those?

@kwagyeman
Copy link
Contributor

kwagyeman commented May 30, 2025

Clock stretching is a very undesirable behavior of I2C.

Do not replicate that. It makes the bus unreliable as it means I2C transfers are not accomplished in fixed time. This kills any MCUs ability to meet a schedule with an I2C shared with multiple devices.

@robert-hh
Copy link
Contributor

robert-hh commented May 30, 2025

Teensy already needs D0/D1 and D2/D3 and D11/D12 connected

With the test of PWM and UART, these Pins are pairwise connected, D0 to D1, D2 to D3 and D11 to D12. For I2C, SDA (A4) and and SCL(A5) have to be connected each to a GPIO pin. So I suggest to use A3 and A6, being just next to SDA and SCL. At the Teensy, SCL and SDA need an external pull-up resistor for a reliable test. The internal pull-up is not sufficiently strong.

@robert-hh
Copy link
Contributor

@dpgeorge The MIMXRT implementation is at a stable state with the feature set matching the RP2. It works fine so far.

@chrismas9 I was hoping for a blocking read callback that could update a register with fresh data before the reply using clock stretching during the callback.

Looking at the timing constraints this feature is hard to achieve. Hardware which allows that uses often a double buffer scheme, such that when a value is requested the most recent ADC value is latched to the transmit buffer. Or they need a dedicated command to start a new sampling and offer a status bit that has to be polled, or an INT signal.
What's less critical is a callback that's called after receiving the STOP condition. With that you may be able to emulate a readfrom_mem() by a combo of writeto() and readfrom() calls.

@robert-hh
Copy link
Contributor

There is an implementation for SAMD at https://github.com/robert-hh/micropython/tree/samd_i2c_target. Works well. Tested so far with SAMD51. Tests with SAMD21 follow.

@robert-hh
Copy link
Contributor

@dpgeorge I'm still unsure about the wrapping behavior for writing and reading. I could not find a source where this is defined as to be expected. Do you have a reference?

For writing controller->target the target can signal if the memory overflows by sending NAK. Then the controller would stop sending. For receiving there is no such mechanism. So the alternative to wrapping would be sending dummy data like 0x00 or 0xff.

@robert-hh
Copy link
Contributor

robert-hh commented Jun 17, 2025

I looked into other ports, whether they can support the I2CTargetMemory class.

  • The NRF port it looks promising. The library provides suitable functions.

  • ESP8266 does not provides I"C hardware support at all.

  • The Renesas port seems to have I2C target mode hardware, but the HAL layer does not offer interfaces for using it.

  • The ESP32 situation is crooked for several reasons.

    • The ESP32 API has three versions of I2C support. 1. The legacy API, which is currently used for I2C controller support. 2. The ng API Version 1. 3, The ng API version 2. Option b) is declared as getting expired with idf 6.x. The ng API V2 is available starting version 5.4. So using is at the moment is not possible. The legacy API and ng API must not be used at the same time. So if that is going to be used, the I2C class has to be updated.
    • Implementing the class I2CTargetMemory for trial it turned out that it cannot properly support the target memory mode. The API offers two callback functions or receive and transmit. Each of them is called AFTER the respective bus transactions is finished. That makes it possible to support the writeto_mem() method properly, but fails to support readfrom_mem(). The only option which could be supported is getting the proper content from the previous state of the memory object. After it has been written, one has to call readfrom_mem() twice to get the proper content. It may be useful to have a I2CTargetMemory.write() method which fills the transmit buffer with data from a provided object.
    • One has to declare a suitable large size for the temporary buffer. Otherwise, if on writeto_mem() the sent data is longer than the declared buffer, the amount of data actually provided to the callback is erratic. On readfrom_mem() the reading does not wrap. Instead for the excess data 0xff is sent.

@dpgeorge
Copy link
Member Author

Thanks @robert-hh for working on this for the other ports, that's very helpful!

Right now I'm reworking the API here to take into account the feedback, mainly to provide IRQs/callbacks for various I2C target events. After that's done we can integrate your changes.

I'm still unsure about the wrapping behavior for writing and reading. I could not find a source where this is defined as to be expected. Do you have a reference?

It's not really defined as a standard thing, it's just that a lot of devices will wrap around if you read beyond the end of their register range. Eg I think the MMA on the PYBv1.x does this?

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
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants