FastArduino v1.10
C++ library to build fast but small Arduino/AVR projects
|
There are plenty of devices of all kinds, based on I2C interface, that you may want to connect to your Arduino or a board you created with an AVR ATmega or ATtiny MCU.
If you want to learn more about I2C concepts and vocabulary, you can find further information on Wikipedia.
Unfortunately, FastArduino obviously cannot provide specific support for all existing I2C devices.
However, based on a given device datasheet, it can be quite easy to add a FastArduino driver for any I2C device.
FastArduino provides all the necessary classes and methods for you to implement such a specific driver.
The following sections describe the FastArduino API for I2C device driver implementation, and list the steps to successfully implement such a driver.
The generic support for I2C device driver in FastArduino looks quite simple, it is entirely embedded in one class, i2c::I2CDevice
; this is a template class which all actual I2C device drivers shall derive from.
This template class has only one MANAGER
parameter, which must be kept as is in all subclasses; this represents the type of I2C Manager used to handle the I2C bus and operations.
The i2c::I2CDevice
class mainly contains protected
types aliases and methods to create and launch read and write commands to a device on the I2C bus.
Any FastArduino I2C device must be able to work in both asynchronous and synchronous modes The i2c::I2CDevice
API is made for asynchronous operations; synchronous flavours of a specific device API are based on asynchronous implementations of that API (just awaiting for the operation to finish).
As you can see in the following diagrams, the drivers for I2C devices currently supported by FastArduino directly derive from i2c::I2CDevice
:
Creating a new driver for an I2C device must follow these steps:
i2c::I2CDevice
template subclass; let's call it MyI2CDevice
in the rest of this page.private
) the following type aliases inherited from i2c::I2CDevice
: PARENT
, FUTURE
public
constructor with one argument: MyI2CDevice::MyI2CDevice(MANAGER& manager)
where MANAGER
is a class template argument of both MyI2CDevice
and i2c::I2CDevice
; this constructor must call the inherited constructor and pass it 3 arguments: manager
, the default I2C address for your device, and finally, one of i2c::I2C_STANDARD
or i2c::I2C_FAST
constants, to indicate the best mode (highest I2C frequency) that your device can support.public
API you need to provide, define a specific Future to hold values written to the device, as well as values later read from the device. Each defined Future shall derive from FUTURE
(type alias defined above). This future will allow asynchronous execution of the API. FastArduino guidelines for I2C devices suggest to name the future class according to the API name itself e.g. SetDatetimeFuture
for the set_datetime()
API.public
API, define a method that takes a reference to the future defined above and return an int
. The implementation of this method is based on mainly 3 inherited protected
methods: i2c::I2CDevice.read()
, i2c::I2CDevice.write()
and i2c::I2CDevice.launch_commands()
. In simple situations, there are even simpler methods that your I2C device API can call: i2c::I2CDevice.async_read()
, i2c::I2CDevice.sync_read()
, i2c::I2CDevice.async_write()
and i2c::I2CDevice.sync_write()
.public
API, also define a similar method (same name) with a synchronous flavour. That method will directly take "natural" arguments (no futures) as input or output (reference), and return a bool
to indicate if API was performed without any error.Before describing FastArduino I2C Device API, it is important to mention that this API is heavily based on FastArduino future
API, which concepts shall be first understood before starting to build your own support for an I2C device.
Subclassing i2c::I2CDevice
gives MyI2CDevice
access to all low-level protected
aliases:
PARENT
: this is simply defined as i2c::I2CDevice<MANAGER>
and is useful for accessing next aliasesi2c::I2CDevice::FUTURE
: this is the type of Future used by MANAGER
; it must be used for defining your own Future types for all asynchronous API of MyI2CDevice
Note that to be accessible from MyI2CDevice
class, these types must be redefined as follows:
i2c::I2CDevice
constructor takes 4 arguments:
MANAGER& manager
: this should be passed as is from MyI2CDevice
constructoruint8_t device
: this is the default I2C address of this device (this 7-bits address must be already left-shifted one bit to leave the LSB available for I2C direction read or write)Mode<MODE> mode
(MODE
is a template argument of the constructor, i2c::I2CMode MODE
): this should be passed one of 2 constants i2c::I2C_FAST
or i2c::I2C_STANDARD
) to indicate the best I2C mode (highest frequency) supported by MyI2CDevice
: this will impact what MANAGER
type can be used when instantiating MyI2CDevice
templatebool auto_stop
: this defines whether all chains of commands of MyI2CDevice
shall automatically be ended with a STOP condition on the I2C bus or not. In most cases, the default (false
) should work, but some devices (e.g. DS1307) do not work properly if 2 chains of commands are not separated by a STOP condition.Note that device
address must be provided at construction time but can optionally be changed later. Regarding its I2C address, typically an I2C device falls in one of the following categories:
For devices in category 1, you would typically define the address as a constant in MyI2CDevice
and pass it directly to i2c::I2CDevice
constructor.
Here is a snippet from DS1307 device:
For devices in category 2, you would rather define an enum class
limiting the possible addresses configurable by hardware, or pass the address (as uint8_t
) to the driver class constructor.
For devices in category 3, you would first define the fixed default address as a constant, then define an API to change it (as a data member of MyI2CDevice
).
Subclassing i2c::I2CDevice
gives MyI2CDevice
access to all low-level protected
methods:
i2c::I2CDevice.read(uint8_t read_count, bool finish_future, bool stop)
: create a command to read bytes from the I2C device; read bytes will be added to the related Future (passed to launch_commands()
)i2c::I2CDevice.write(uint8_t write_count, bool finish_future, bool stop)
:create a command to write bytes to the I2C device; written bytes are taken from the related Future (passed to launch_commands()
)i2c::I2CDevice.launch_commands(ABSTRACT_FUTURE& future, initializer_list<> commands)
: prepare passed read/write commands
and send them to MANAGER
for later asynchronous execution (commands are queued); the Future
referenced by future
is used to provide data to write to, and store data to read from, the I2C device.i2c::I2CDevice.set_device(uint8_t device)
: change the I2C address of this device. This is useful for devices that allow changing their I2C address by software.Note that read()
and write()
methods do not actually perform any I2C operation! They only prepare an I2C read or write command (i2c::I2CLightCommand
type embedding necessary information) based on their arguments:
read_count
/write_count
specify the number of bytes to read or write. When 0
(the default), this means that all bytes (as defined in the specific Future) will be read or written.finish_future
: specify that, after this command execution, the Future assigned to the current transaction will be marked as finishedstop
: specify that, after this command execution, an I2C STOP condition will be generated on the I2C bus; this will automatically trigger a "START" condition on the next command (whether it is part of the current chain of commands or not)The launch_commands()
method does the actual work:
READY
(or in ERROR
) when the method returnsREADY
or ERROR
) once all commands are complete.In addtion to low-levels methods discussed above, i2c::I2CDevice
also provides the following higher-level methods that will make it even simpler to use for simple cases:
i2c::I2CDevice.async_read(F& future, bool stop = true)
: start an asynchronous read of future which type is F
(template argument of the method)i2c::I2CDevice.sync_read(T& result)
: start a synchronous read through future which type is F
(1st template argument of the method) and await for result of type T
(2nd template argument of the method)i2c::I2CDevice.async_write(F& future, bool stop = true)
: start an asynchronous write of future which type is F
(template argument of the method)i2c::I2CDevice.sync_write(const T& value)
: start a synchronous write of value of type T
(2nd template argument of the method) through future which type is F
(1st template argument of the method) and await until write is finished.All these methods are based on low-level methods but they make the implementation of your device API a blaze (one-liner for each API method). These methods work in simple situations which typically represent most cases you will have to deal with for a given I2C chip:
As long as your API fits in these situations, those high-level API can apply. Here is an example excerpted from DS1307
device implementation:
In this snippet you see 4 one-liner examples showing each high-level read/write methods.
Although each of these methods is a template, only the "sync" flavours require explicit template argument for the Future used. Indeed, sync methods need to instantiate the future and cannot "guess" the future type to instantiate.
Those high-level methods will not be used in the case where you need several writes or several reads in the same I2C transaction. For instance, the MPU6050
device has some cases where several write commands must be processed but with a "REPEAT START" condition in between:
In that snippet, BeginFuture
is prepared to:
CONFIG
registerPWR_MGMT_1
registerbegin()
implementation then calls launch_commands()
with 2 distinct write commands.
Most I2C devices API consist in reading and writing device registers at a specific address (referenced by a byte); registers may be one byte long or more depending on what each register represents.
In order to simplify support of new I2C devices, FastArduino comes with a few extra utilities that can greatly speed up device support implementation.
These utilities are in header i2c_device_utilities.h
in the same i2c
namespace as I2CDevice
abstract base class.
The following template classes are defined in there:
ReadRegisterFuture
and TReadRegisterFuture
: future classes to read one register of any type of value; type conversion is possible by providing a FUNCTOR
class or function.WriteRegisterFuture
and TWriteRegisterFuture
: future classes to write one register of any type; type conversion is possible by providing a FUNCTOR
class or function.I2CFuturesGroup
: abstract future allowing its derived classes to aggregate several futures used in the same I2C transaction; this is useful when dealing with particularly complex I2C devices.I2CSameFutureGroup
: instances of this class will generate one-byte register writing I2C transactions from content (register id and register value) stored in Flash; this is useful when dealing with some I2C devices that need long initialization process from hard-coded values.The DS1307
RTC device is a good example of TReadRegisterFuture
and TWriteRegisterFuture
simple usage, along with conversion functors:
The VL53L0X
Time-of-Flight laser device is much complex and makes heavy use of advanced utilities like I2CFuturesGroup
:
We see in the above example the future SetGPIOSettingsFuture
that aggregates 5 futures to write values to distinct registers. Device method set_GPIO_settings()
shows the peculiar way to start I2C commands directly through the future.start()
method.
Handling of the I2C bus by the I2C Manager and the I2CDevice
follows standard I2C protocol, with some level of "intelligence".
In usual conditions: launch_commands()
can execute long chains of commands on one device:
This default behaviour allows your I2C device API implementation to perform a sequence of calls to the I2C device, where the first call will acquire the bus, and all following calls in between will not need to acquire the bus.
You can change the default for each command or for a whole device:
stop
argument to true
, which will produce a "STOP" condition at the end of that command and a "START" condition on the next command in the chain.auto_stop
constructor argument to true
; then all chains of commands for the device will always end with a STOP condition.IMPORTANT: Actually, asynchronous flavours of I2C Managers will release the I2C bus at the end of an I2C transaction, in case there is no more pending command (from another I2C transaction) in the commands queue.
For many I2C devices, communication is based on writing and reading "registers", each device having its own list of specific registers. Hence most I2C device drivers API will consist in reading or writing one register.
In FastArduino, drivers like devices::mcp230xx::MCP23008
first define private
generic Future classes, later used by specific API to read registers:
In this snippet, a base Future, ReadRegisterFuture
, is defined. It will serve as a base class for all specific Futures needed by all API reading registers, like in the following excerpt from MCP23008 device:
In the above code, we heavily depend on I2C device utilities: GetValuesFuture
is just defines as an alias based on TReadRegisterFuture
for register address GPIO
; this allows callers of the values()
API to directly instantiate this Future without further input.
The implementation of values()
is a one-liner that requires no further explanation.
A similar approach is used for writing a value to a device register and will not be detailed here.
In general, before developing a full-fledged driver for an I2C device, you need to learn how to use that device.
Based on the device datasheet, you first learn how to manipulate the device through the I2C bus.
For better understanding, you generally use a debugging example that helps demonstrate how the device works.
One easy way to develop such a debugging sample is to create a program with just one source code file containing:
#include
directivesPublicDevice
class that derives from i2c::I2CDevice
but declares main()
as a friend
, which allows direct calls, from main()
, to protected
API of i2c::I2CDevice
, for easy testingPublicDevice
instance, from main()
and trace results to a console, through UARTI2CSyncDebugManager
or I2CSyncDebugStatusManager
, which allow tracing (live or later) all steps of I2C transactionsFastArduino includes such a debugging sample in examples/i2c/I2CDeviceProto
example, copied hereafter:
This example is just an empty skeleton for your own tests. It is made of several parts:
Those lines include a few headers necessary (or just useful) to debug an I2C device.
Any specificity of the tested I2C device is defined as a constant in the next code section. Note the definition of DEVICE_ADDRESS
constant: this 7-bit I2C device address is shifted one bit left as an 8th bit will be added (I2C communication protocol) to define data direction for each transmission.
This section defines various types aliases, for I2C Manager, I2C debugger, and types used as part of device API definition. In addition, a DEBUG
macro to debug all I2C steps after API execution is defined.
Then an output stream is created for tracing through UART, and the necessary UART ISR is registered. Also, we declare that no future listener is used; this is needed because you shall use futures in your tests. Note that if you use complex futures, you may need to register future listeners, but that is for complex I2C devices only (e.g. VL53L0X).
This is where we define a utility class to debug our I2C interface to the tested device. PublicDevice
class does nothing but making all protected methods callable from main()
, so that we can directly perform our code tests in main()
, without thinking much about proper API design now.
This is the main()
function where it all happens. First we initialize the MCU and the UART for tracing.
Here we simply initialize I2C function on the UNO.
We then declare the device
variable that we will use for testing our I2C device.
Finally the rest of the code is placeholder for any initialization API, followed by an infinite loop where you can call sync_read/sync_write or launch_commands/read/write methods on device
in order to test the way to handle the target device.
At this level, you have already been able to debug how the device works and you have a good overview of what features you want to provide to developers (and to yourself as the first of all) who will want to use this device.
An easy way is to provide an API that maps every feature found in the datasheet to its dedicated method. This is what we would call a low-level API; that is the minimum your driver should provide.
Additionally MyI2CDevice
might implement a higher level API, based on the low-level one, but this is not mandatory; actually, this is not even advised generally, as this high-level API might be implemented in a distinct class. Using a separate class for high-level API allows other developers to develop their own high-level API without having to use yours if it does not fit their needs.
It is often advised to add begin()
and end()
methods to MyI2CDevice
when it makes sense. begin()
would initialize the device before usage (most devices will require special setup before use).
This step consists in implementing the API defined in the step before.
Typically every API will be made of:
get()
methodI2CDevice
methods (launch_commands()
, with write()
and read()
calls to prepare I2C commands), or high-level methods (async_read()
, async_write()
), as described abovesync_read()
, sync_write()
).Previous sections provide snippets.
Bravo! You successfully added FastArduino support, in your own project, for a specific I2C device!
The last mile would now consist in adding your valuable work to FastArduino library! You do not have to, of course, but this would be a good way to:
However, like for a marathon, the last mile can be difficult! In order to run this last mile, you will have to:
public
methods, and advised for protected
ones.examples/i2c
directory; examples must be kept simple but still demonstrate the API usage; example circuits (connection pins) shall be described. These examples can be further used as "tests" before new releases of FastArduino.Important condition: in order to accept merging a PR to FastArduino, I must be able to check it by myself, hence I need to first have the new supported device available on my workbench; I will gladly buy one (or a few) if it is affordable and easy to find.