This library implements an asynchronous bidirectional communication link
between MicroPython targets using I2C. It presents a UART-like interface
supporting StreamReader
and StreamWriter
classes. In doing so, it emulates
the behaviour of a full duplex link despite the fact that the underlying I2C
link is half duplex.
One use case is to provide a UART-like interface to an ESP8266 while leaving the one functional UART free for the REPL.
The blocking nature of the MicroPython I2C device driver is mitigated by hardware synchronisation on two wires. This ensures that the slave is configured for a transfer before the master attempts to access it.
The Pyboard or similar STM based boards are currently the only targets
supporting I2C slave mode. Consequently at least one end of the interface
(known as theInitiator
) must be a Pyboard or other board supporting the pyb
module. The Responder
may be any hardware running MicroPython and supporting
machine
.
If the Responder
(typically an ESP8266) crashes the resultant I2C failure is
detected by the Initiator
which can issue a hardware reboot to the
Responder
enabling the link to recover. This can occur transparently to the
application and is covered in detail
in section 5.3.
V0.17 Dec 2018 Initiator: add optional "go" and "fail" user coroutines.
V0.16 Minor improvements and bugfixes. Eliminate timeout
option which caused
failures where Responder
was a Pyboard.
V0.15 RAM allocation reduced. Flow control implemented.
V0.1 Initial release.
- Files
- Wiring
- Design
- API
4.1 Channel class
4.2 Initiator class
4.2.1 Configuration Fine-tuning the interface.
4.2.2 Optional coroutines
4.3 Responder class - Limitations
5.1 Blocking
5.2 Buffering and RAM usage
5.3 Responder crash detection - Hacker notes For anyone wanting to hack on the code.
asi2c.py
Module for theResponder
target.asi2c_i.py
TheInitiator
target requires this andasi2c.py
.i2c_init.py
Initiator test/demo to run on a Pyboard.i2c_resp.py
Responder test/demo to run on a Pyboard.i2c_esp.py
Responder test/demo for ESP8266.
Dependency:
uasyncio
Official library or my fork.
Pyboard | Target | Comment |
---|---|---|
gnd | gnd | |
sda | sda | I2C |
scl | scl | I2C |
sync | sync | Any pin may be used. |
ack | ack | Any pin. |
rs_out | rst | Optional reset link. |
The sync
and ack
wires provide synchronisation: pins used are arbitrary. In
addition provision may be made for the Pyboard to reset the target if it
crashes and fails to respond. If this is required, link a Pyboard pin to the
target's reset
pin.
I2C requires the devices to be connected via short links and to share a common
ground. The sda
and scl
lines also require pullup resistors. On the Pyboard
V1.x these are fitted. If pins lacking these resistors are used, pullups to
3.3V should be supplied. A typical value is 4.7KΩ.
The I2C specification is asymmetrical: only master devices can initiate transfers. This library enables slaves to initiate a data exchange by interrupting the master which then starts the I2C transactions. There is a timing issue in that the I2C master requires that the slave be ready before it initiates a transfer. Further, in the MicroPython implementation, a slave which is ready will block until the transfer is complete.
To meet the timing constraint the slave must initiate all exchanges; it does
this by interrupting the master. The slave is therefore termed the Initiator
and the master Responder
. The Initiator
must be a Pyboard or other STM
board supporting slave mode via the pyb
module.
To enable Responder
to start an unsolicited data transfer, Initiator
periodically interrupts Responder
to cause a data exchange. If either
participant has no data to send it sends an empty string. Strings are exchanged
at a fixed rate to limit the interrupt overhead on Responder
. This implies a
latency on communications in either direction; the rate (maximum latency) is
under application control. By default it is 100ms.
The module will run under official or fast_io
builds of uasyncio
. Owing to
the latency discussed above, the choice has little effect on the performance of
this interface.
A further issue common to most communications protocols is synchronisation:
the devices won't boot simultaneously. Initially, and after the Initiator
reboots the Responder
, both ends run a synchronisation phase. The interface
starts to run once each end has determined that its counterpart is ready.
The design assumes exclusive use of the I2C interface. Hard or soft I2C may be used.
The following scripts demonstrate basic usage. They may be copied and pasted at the REPL. They assume a Pyboard linked to an ESP8266 as follows:
Pyboard | ESP8266 | Notes |
---|---|---|
gnd | gnd | |
X9 | 0 | I2C scl |
X10 | 2 | I2C sda |
X11 | 5 | syn |
X12 | rst | Optional |
Y8 | 4 | ack |
On Pyboard:
import uasyncio as asyncio
from pyb import I2C # Only pyb supports slave mode
from machine import Pin
import asi2c_i
i2c = I2C(1, mode=I2C.SLAVE)
syn = Pin('X11')
ack = Pin('Y8')
rst = (Pin('X12'), 0, 200)
chan = asi2c_i.Initiator(i2c, syn, ack, rst)
async def receiver():
sreader = asyncio.StreamReader(chan)
while True:
res = await sreader.readline()
print('Received', int(res))
async def sender():
swriter = asyncio.StreamWriter(chan, {})
n = 0
while True:
await swriter.awrite('{}\n'.format(n))
n += 1
await asyncio.sleep_ms(800)
loop = asyncio.get_event_loop()
loop.create_task(receiver())
loop.create_task(sender())
try:
loop.run_forever()
finally:
chan.close() # for subsequent runs
On ESP8266:
import uasyncio as asyncio
from machine import Pin, I2C
import asi2c
i2c = I2C(scl=Pin(0),sda=Pin(2)) # software I2C
syn = Pin(5)
ack = Pin(4)
chan = asi2c.Responder(i2c, syn, ack)
async def receiver():
sreader = asyncio.StreamReader(chan)
while True:
res = await sreader.readline()
print('Received', int(res))
async def sender():
swriter = asyncio.StreamWriter(chan, {})
n = 1
while True:
await swriter.awrite('{}\n'.format(n))
n += 1
await asyncio.sleep_ms(1500)
loop = asyncio.get_event_loop()
loop.create_task(receiver())
loop.create_task(sender())
try:
loop.run_forever()
finally:
chan.close() # for subsequent runs
This is the base class for Initiator
and Responder
subclasses and provides
support for the streaming API. Applications do not instantiate Channel
objects.
Method:
close
No args. Restores the interface to its power-up state.
Coroutine:
ready
No args. Pause until synchronisation has been achieved.
i2c
AnI2C
instance.pin
APin
instance for thesync
signal.pinack
APin
instance for theack
signal.reset=None
Optional tuple defining a reset pin (see below).verbose=True
IfTrue
causes debug messages to be output.cr_go=False
Optional coroutine to run at startup. See 4.2.2.go_args=()
Optional tuple of args for above coro.cr_fail=False
Optional coro to run on ESP8266 fail or reboot.f_args=()
Optional tuple of args for above.
The reset
tuple consists of (pin
, level
, time
). If provided, and the
Responder
times out, pin
will be set to level
for duration time
ms. A
Pyboard or ESP8266 target with an active low reset might have:
(machine.Pin('X12'), 0, 200)
If the Initiator
has no reset
tuple and the Responder
times out, an
OSError
will be raised.
Pin
instances passed to the constructor must be instantiated by machine
.
t_poll=100
Interval (ms) forInitiator
pollingResponder
.rxbufsize=200
Size of receive buffer. This should exceed the maximum message length.
See Section 4.2.1.
The Initiator
maintains instance variables which may be used to measure its
peformance. See Section 4.2.1.
reboot
If areset
tuple was provided, reboot theResponder
.
The Initiator
class variables determine the behaviour of the interface. Where
these are altered, it should be done before instantiating Initiator
or
Responder
.
Initiator.t_poll
This defines the polling interval for incoming data. Shorter
values reduce the latency when the Responder
sends data; at the cost of a
raised CPU overhead (at both ends) in processing Responder
polling.
Times are in ms.
To measure performance when running application code these Initiator
instance
variables may be read:
nboots
Number of timesResponder
has failed and been rebooted.block_max
Maximum blocking time in μs.block_sum
Cumulative total of blocking time (μs).block_cnt
Transfer count: mean blocking time isblock_sum/block_cnt
.
See test program i2c_init.py
for an example of using the above.
These are intended for applications where the Responder
may reboot at runtime
either because I2C failure was detected or because the application issues an
explicit reboot command.
The cr_go
and cr_fail
coroutines provide for applications which implement
an application-level initialisation sequence on first and subsequent boots of
the Responder
. Such applications need to ensure that the initialisation
sequence does not conflict with other coros accessing the channel.
The cr_go
coro runs after synchronisation has been achieved. It runs
concurrently with the coro which keeps the link open (Initiator._run()
), but
should run to completion reasonably quickly. Typically it performs any app
level synchronisation, starts or re-enables application coros, and quits.
The cr_fail
routine will prevent the automatic reboot from occurring until
it completes. This may be used to prevent user coros from accessing the channel
until reboot is complete. This may be done by means of locks or task
cancellation. Typically cr_fail
will terminate when this is done, so that
cr_go
has unique access to the channel.
If an explicit .reboot()
is issued, a reset tuple was provided, and cr_fail
exists, it will run and the physical reboot will be postponed until it
completes.
Typical usage:
chan = asi2c_i.Initiator(i2c, syn, ack, rst, verbose, self._go, (), self._fail)
i2c
AnI2C
instance.pin
APin
instance for thesync
signal.pinack
APin
instance for theack
signal.verbose=True
IfTrue
causes debug messages to be output.
Pin
instances passed to the constructor must be instantiated by machine
.
addr=0x12
Address of I2C slave. This should be set before instantiatingInitiator
orResponder
. If the default address (0x12) is to be overriden,Initiator
application code must instantiate the I2C accordingly.rxbufsize=200
Size of receive buffer. This should exceed the maximum message length. Consider reducing this in ESP8266 applications to save RAM.
Exchanges of data occur via Initiator._sendrx()
, a synchronous method. This
blocks the schedulers at each end for a duration dependent on the number of
bytes being transferred. Tests were conducted with the supplied test scripts
and the official version of uasyncio
. Note that these scripts send short
strings.
With Responder
running on a Pyboard V1.1 the duration of the ISR was up to
1.3ms.
With Responder
on an ESP8266 running at 80MHz, Initiator
blocked for up to
10ms with a mean time of 2.7ms; at 160MHz the figures were 7.5ms and 2.1ms. The
ISR uses soft interrupts, and blocking commences as soon as the interrupt pin
is asserted. Consequently the time for which Initiator
blocks depends on
Responder
's interrupt latency; this may be extended by garbage collection.
Figures are approximate: actual blocking time is dependent on the length of the strings, the speed of the processors, soft interrupt latency and the behaviour of other coroutines. If blocking time is critical it should be measured while running application code.
The protocol implements flow control: the StreamWriter
at one end of the link
will pause until the last string transmitted has been read by the corresponding
StreamReader
.
Outgoing data is unbuffered. StreamWriter.awrite
will pause until pending
data has been transmitted.
Incoming data is stored in a buffer whose length is set by the rxbufsize
constructor arg. If an incoming payload is too long to fit the buffer a
ValueError
will be thrown.
The Responder
protocol executes in a soft interrupt context. This means that
the application code might fail (for example executing an infinite loop) while
the ISR continues to run; Initiator
would therefore see no problem. To trap
this condition regular messages should be sent from Responder
, with
Initiator
application code timing out on their absence and issuing reboot
.
This also has implications when testing. If a Responder
application is
interrupted with ctrl-c
the ISR will continue to run. To test crash detection
issue a soft or hard reset to the Responder
.
I tried a variety of approaches before settling on a synchronous method for data exchange coupled with 2-wire hardware handshaking. The chosen approach minimises the time for which the schedulers are blocked. Blocking occurs because of the need to initiate a blocking transfer on the I2C slave before the master can initiate a transfer.
A one-wire handshake using open drain outputs is feasible but involves explicit
delays. I took the view that a 2-wire solution is easier should anyone want to
port the Responder
to a platform such as the Raspberry Pi. The design has no
timing constraints and uses normal push-pull I/O pins.
I experienced a couple of obscure issues affecting reliability. Calling pyb
I2C
methods with an explicit timeout caused rare failures when the target was
also a Pyboard. Using micropython.schedule
to defer RAM allocation also
provoked rare failures. This may be the reason why I never achieved reliable
operation with hard IRQ's on ESP8266.
I created a version which eliminated RAM allocation by the Responder
ISR to
use hard interrupts. This reduced blocking further. Unfortunately I failed to
achieve reliable operation on an ESP8266 target. This version introduced some
complexity into the code so was abandoned. If anyone feels like hacking, the
branch i2c_hard_irq
exists.
The main branch aims to minimise allocation while achieving reliability.
PR's to reduce allocation and enable hard IRQ's welcome. I will expect them to run the two test programs for >10,000 messages with ESP8266 and Pyboard targets. Something I haven't yet achieved (with hard IRQ's).