FastArduino v1.10
C++ library to build fast but small Arduino/AVR projects
Loading...
Searching...
No Matches
Adding support for an I2C device

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.

FastArduino I2C driver API

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:

  1. DS1307 Real Time Clock chip
  2. MCP23008 8-Bit I/O Expander chip
  3. MCP23017 16-Bit I/O Expander chip
  4. MPU6050 3D Accelerometer-Gyroscope chip
  5. HMC5883L 3D Compass chip
  6. VL53L0X Time of Flight ranging sensor

Creating a new driver for an I2C device must follow these steps:

  1. Create a i2c::I2CDevice template subclass; let's call it MyI2CDevice in the rest of this page.
  2. Redefine (as private) the following type aliases inherited from i2c::I2CDevice: PARENT, FUTURE
  3. Add a 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.
  4. List the API you need to provide to the end user of your device (based on the device datasheet)
  5. For each 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.
  6. For each 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().
  7. For each 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.

I2CDevice API

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 aliases
  • i2c::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:

private:
using PARENT = i2c::I2CDevice<MANAGER>;
template<typename OUT, typename IN> using FUTURE = typename PARENT::template FUTURE<OUT, IN>;
Base class for all I2C devices.
Definition: i2c_device.h:84

i2c::I2CDevice constructor takes 4 arguments:

  1. MANAGER& manager: this should be passed as is from MyI2CDevice constructor
  2. uint8_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)
  3. 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 template
  4. bool 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:

  1. it has a fixed I2C address that cannot be changed (e.g. DS1307 RTC chip)
  2. it has an I2C address that can be changed by hardware (e.g. jumpers) among a limited range of possible addresses (e.g. MCP23017 I/O expander chip, MPU 6050 acceleromete/gyroscope chip)
  3. it has a fixed default I2C address that can be changed by software (e.g. VL53L0X "micro lidar" chip); this is generally a bit harder to support (extra API to implement for your I2C device).

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:

public:
explicit DS1307(MANAGER& manager) : PARENT{manager, DEVICE_ADDRESS, i2c::I2C_STANDARD, true} {}
private:
static constexpr const uint8_t DEVICE_ADDRESS = 0x68 << 1;
Define API to define and manage I2C devices.
Definition: i2c.h:51

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:

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 finished
  • stop: 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:

  • with a synchronous I2C Manager, it blocks until all commands get executed or an error occurs; the assigned Future is directly READY (or in ERROR) when the method returns
  • with an asynchronous I2C Manager, it enqueues all commands for asynchronous execution and returns immediately; the assigned Future will be later updated (it status will become either READY 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:

  • read is made of a write part followed by a read part (in the same I2C transaction)
  • write is made only of a write part

As long as your API fits in these situations, those high-level API can apply. Here is an example excerpted from DS1307 device implementation:

int set_datetime(SetDatetimeFuture& future)
{
return this->async_write(future);
}
bool set_datetime(const tm& datetime)
{
return this->template sync_write<SetDatetimeFuture>(datetime);
}
int get_datetime(GetDatetimeFuture& future)
{
return this->async_read(future);
}
bool get_datetime(tm& datetime)
{
return this->template sync_read<GetDatetimeFuture>(datetime);
}
Contains the API around Future implementation.
Definition: future.h:312

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:

class BeginFuture : public FUTURE<void, containers::array<uint8_t, 6>>
{
using PARENT = FUTURE<void, containers::array<uint8_t, 6>>;
public:
explicit BeginFuture( GyroRange gyro_range = GyroRange::RANGE_250,
AccelRange accel_range = AccelRange::RANGE_2G,
DLPF low_pass_filter = DLPF::ACCEL_BW_260HZ,
ClockSelect clock_select = ClockSelect::INTERNAL_8MHZ)
: PARENT{{ CONFIG, uint8_t(low_pass_filter), uint8_t(gyro_range), uint8_t(accel_range),
PWR_MGMT_1, utils::as_uint8_t(PowerManagement{clock_select})}} {}
};
int begin(BeginFuture& future)
{
// We split the transaction in 2 write commands (3 bytes starting at CONFIG, 1 byte at PWR_MGT_1)
return this->launch_commands(future, {this->write(4), this->write(2)});
}
constexpr uint8_t as_uint8_t(T input)
Cast a one byte long bit-fields struct into a byte.
Definition: utilities.h:506

In that snippet, BeginFuture is prepared to:

  1. write 3 consecutive registers (1 byte each), starting at CONFIG register
  2. write 1 register (1 byte), at PWR_MGMT_1 register

begin() implementation then calls launch_commands() with 2 distinct write commands.

I2C device registers common operations

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:

using GetDatetimeFuture = TReadRegisterFuture<TIME_ADDRESS, tm, DatetimeConverterFromDevice>;
using SetDatetimeFuture = TWriteRegisterFuture<TIME_ADDRESS, tm, DatetimeConverterToDevice>;

The VL53L0X Time-of-Flight laser device is much complex and makes heavy use of advanced utilities like I2CFuturesGroup:

class SetGPIOSettingsFuture : public I2CFuturesGroup
{
public:
explicit SetGPIOSettingsFuture(const vl53l0x::GPIOSettings& settings)
: I2CFuturesGroup{futures_, NUM_FUTURES},
write_config_{settings.function()},
write_GPIO_active_high_{uint8_t(settings.high_polarity() ? GPIO_LEVEL_HIGH : GPIO_LEVEL_LOW)},
write_low_threshold_{settings.low_threshold() / 2},
write_high_threshold_{settings.high_threshold() / 2}
{
I2CFuturesGroup::init(futures_);
}
~SetGPIOSettingsFuture()
{
}
private:
static constexpr uint8_t GPIO_LEVEL_HIGH = 0x11;
static constexpr uint8_t GPIO_LEVEL_LOW = 0x01;
TWriteRegisterFuture<Register::SYSTEM_INTERRUPT_CONFIG_GPIO, vl53l0x::GPIOFunction> write_config_;
TWriteRegisterFuture<Register::GPIO_HV_MUX_ACTIVE_HIGH> write_GPIO_active_high_;
TWriteRegisterFuture<Register::SYSTEM_THRESH_LOW, uint16_t> write_low_threshold_;
TWriteRegisterFuture<Register::SYSTEM_THRESH_HIGH, uint16_t> write_high_threshold_;
TWriteRegisterFuture<Register::SYSTEM_INTERRUPT_CLEAR> clear_interrupt_{0};
static constexpr uint8_t NUM_FUTURES = 5;
ABSTRACT_FUTURE* futures_[NUM_FUTURES] =
{
&write_config_,
&write_GPIO_active_high_,
&write_low_threshold_,
&write_high_threshold_,
&clear_interrupt_
};
};
int set_GPIO_settings(SetGPIOSettingsFuture& future)
{
return (future.start(*this) ? 0 : future.error());
}
void register_handler(Handler &handler)
Register a class instance containing methods that shall be called back by an ISR.
Definition: interrupts.h:185
void unregister_handler(Handler &handler)
Unregister a class instance that was previously registered with interrupt::register_handler.
Definition: interrupts.h:207

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.

I2C Bus handling

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:

  1. The first command in the chain will generate a "START" condition on the I2C bus as required by the I2C protocol
  2. By default, all following commands will be preceded by a "REPEAT START" condition on the I2C bus
  3. By default, the last command in the chain will not end with a "STOP" condition on the bus; FastArduino I2C Manager will keep the I2C bus for itself, unless required otherwise by I2C devices implementation.

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:

  • at command level, by setting 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.
  • at device level, by setting 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.

API Typical Example

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:

class ReadRegisterFuture : public FUTURE<uint8_t, uint8_t>
{
using PARENT = FUTURE<uint8_t, uint8_t>;
protected:
ReadRegisterFuture(uint8_t address) : PARENT{address} {}
};

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:

// Address of GPIO register (to read digital input pins from MCP23008)
static constexpr const uint8_t GPIO = 0x09;
using GetValuesFuture = TReadRegisterFuture<GPIO>;
int values(GetValuesFuture& future)
{
return this->async_read(future);
}

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.

Debugging support for a new device (low-level)

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:

  • proper #include directives
  • a PublicDevice 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 testing
  • directly call SPI API on a PublicDevice instance, from main() and trace results to a console, through UART
  • use, as I2C Manager, I2CSyncDebugManager or I2CSyncDebugStatusManager, which allow tracing (live or later) all steps of I2C transactions

FastArduino includes such a debugging sample in examples/i2c/I2CDeviceProto example, copied hereafter:

1// Copyright 2016-2023 Jean-Francois Poilpret
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15/*
16 * This is a skeleton program to help connect, debug and understand how a given
17 * I2C device (not already supported by FastArduino) works.
18 * That helps creating a new specific support API for that device for reuse in
19 * other programs and potential integration to FastArduino project.
20 * To ease wiring and debugging, I suggest using a real Arduino board (I typically
21 * use UNO) and a small breadboard for connecting the I2C device.
22 *
23 * Wiring:
24 * NB: you should add pullup resistors (10K-22K typically) on both SDA and SCL lines.
25 * - on Arduino UNO:
26 * - A4 (PC4, SDA): connected to I2C SDA pin
27 * - A5 (PC5, SCL): connected to I2C SCL pin
28 * - direct USB access (traces output)
29 */
30
31#include <fastarduino/time.h>
32#include <fastarduino/future.h>
36#include <fastarduino/uart.h>
38
39// I2C Device specific constants go here
40//======================================
41static constexpr const i2c::I2CMode MODE = i2c::I2CMode::FAST;
42static constexpr const uint8_t DEVICE_ADDRESS = 0x68 << 1;
43
44static constexpr const uint8_t DEBUG_SIZE = 32;
47#define DEBUG(OUT) debugger.trace(OUT)
48
49// The following type aliases will be useful for declaring proper Futures and calling I2CDevice API
50using PARENT = i2c::I2CDevice<MANAGER>;
51template<typename OUT, typename IN> using FUTURE = typename PARENT::template FUTURE<OUT, IN>;
52
53// Define vectors we need in the example
55REGISTER_OSTREAMBUF_LISTENERS(serial::hard::UATX<board::USART::USART0>)
57
58// UART for traces
59static constexpr const uint8_t OUTPUT_BUFFER_SIZE = 64;
60static char output_buffer[OUTPUT_BUFFER_SIZE];
61static serial::hard::UATX<board::USART::USART0> uart{output_buffer};
62static streams::ostream out = uart.out();
63
64// Subclass I2CDevice to make protected methods available
65class PublicDevice: public PARENT
66{
67public:
68 PublicDevice(MANAGER& manager): PARENT{manager, DEVICE_ADDRESS, i2c::Mode<MODE>{}, true} {}
69 friend int main();
70};
71
72using streams::endl;
73using streams::dec;
74using streams::hex;
75
76int main()
77{
79 sei();
80
81 uart.begin(115200);
82 out.width(2);
83
84 // Start TWI interface
85 //====================
86 DEBUGGER debugger;
87 MANAGER manager{debugger, debugger};
88 manager.begin();
90 out << F("I2C interface started") << endl;
91
92 PublicDevice device{manager};
93
94 // Init I2C device if needed
95
96 // Output all debug traces
97 DEBUG(out);
98
99 // Loop to show measures
100 while (true)
101 {
102 // Read measures and display them to UART
103
104 // Output all debug traces
105 DEBUG(out);
106
107 time::delay_ms(1000);
108 }
109
110 // Stop TWI interface
111 //===================
112 manager.end();
113 out << F("End") << endl;
114}
Synchronous I2C Manager for ATmega architecture with status notification and debug facility.
Class recording I2C debug and status notifications for later output.
Definition: i2c_debug.h:364
void width(uint8_t width)
Set minimum width used for displaying values.
Definition: ios.h:390
Output stream wrapper to provide formatted output API, a la C++.
Definition: streams.h:61
#define F(ptr)
Force string constant to be stored as flash storage.
Definition: flash.h:150
Utility API to handle the concept of futures.
#define REGISTER_FUTURE_NO_LISTENERS()
Register no callback at all to any Future notification.
Definition: future.h:166
I2C debugging utilities (useful when implementing support for new devices).
I2C Device API.
Various utilities to use for I2C Device support developers.
Defines all types and constants specific to support a specific MCU target.
Definition: empty.h:38
static void init()
Performs special initialization for the target MCU.
Definition: empty.h:43
DEBUG
Indicate what in I2C protocol shall be debugged.
Definition: i2c_debug.h:102
I2CMode
I2C available transmission modes.
Definition: i2c.h:168
@ FAST
I2C Fast mode, less than 400KHz.
Defines all API for UART features.
Definition: soft_uart.h:84
void endl(FSTREAM &stream)
Manipulator for an output stream, which will insert a new-line character and flush the stream buffer.
Definition: streams.h:725
void boolalpha(FSTREAM &stream)
Set the ios::boolalpha format flag for stream.
Definition: ios.h:788
void hex(FSTREAM &stream)
Manipulator for an output or input stream, which will set the base, used to represent (output) or int...
Definition: ios.h:774
void dec(FSTREAM &stream)
Manipulator for an output or input stream, which will set the base, used to represent (output) or int...
Definition: ios.h:765
void unitbuf(FSTREAM &stream)
Set the ios::unitbuf format flag for stream.
Definition: ios.h:906
void delay_ms(uint16_t ms) INLINE
Delay program execution for the given amount of milliseconds.
Definition: time.h:346
#define REGISTER_OSTREAMBUF_LISTENERS(HANDLER1,...)
Register the necessary callbacks that will be notified when a streams::ostreambuf is put new content ...
Definition: streambuf.h:49
Simple time utilities.
Hardware serial API.
#define REGISTER_UATX_ISR(UART_NUM)
Register the necessary ISR (Interrupt Service Routine) for an serial::hard::UATX to work correctly.
Definition: uart.h:37
General utilities API that have broad application in programs.

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.

static constexpr const i2c::I2CMode MODE = i2c::I2CMode::FAST;
static constexpr const uint8_t DEVICE_ADDRESS = 0x68 << 1;

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.

static constexpr const uint8_t DEBUG_SIZE = 32;
#define DEBUG(OUT) debugger.trace(OUT)
// The following type aliases will be useful for declaring proper Futures and calling I2CDevice API
using PARENT = i2c::I2CDevice<MANAGER>;
template<typename OUT, typename IN> using FUTURE = typename PARENT::template FUTURE<OUT, IN>;

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.

REGISTER_OSTREAMBUF_LISTENERS(serial::hard::UATX<board::USART::USART0>)
// UART for traces
static constexpr const uint8_t OUTPUT_BUFFER_SIZE = 64;
static char output_buffer[OUTPUT_BUFFER_SIZE];
static serial::hard::UATX<board::USART::USART0> uart{output_buffer};
static streams::ostream out = uart.out();

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).

class PublicDevice: public PARENT
{
public:
PublicDevice(MANAGER& manager): PARENT{manager, DEVICE_ADDRESS, i2c::Mode<MODE>{}, true} {}
friend int main();
};

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.

int main()
{
sei();
uart.begin(115200);
out.width(2);

This is the main() function where it all happens. First we initialize the MCU and the UART for tracing.

MANAGER manager{debugger, debugger};
manager.begin();

Here we simply initialize I2C function on the UNO.

PublicDevice device{manager};

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.

Defining the driver API based on device features

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).

Implementing the driver API

This step consists in implementing the API defined in the step before.

Typically every API will be made of:

  • a specific Future class that encapsulates input arguments (in its constructor) and holds place for output; this Future shall embed any necessary conversion of input arguments if needed, as well as conversion of output, through override of get() method
  • one asynchronous method taking as only argument a reference to the Future defined above, and calling either low-level I2CDevice methods (launch_commands(), with write() and read() calls to prepare I2C commands), or high-level methods (async_read(), async_write()), as described above
  • one synchronous method taking same arguments as Future constructor defined above, plus a reference argument for any output; this method instantiates the above Future, calls the asynchronous method defined before, and awaits the Future to be ready and get its output; another simpler option could use high-level methods (sync_read(), sync_write()).

Previous sections provide snippets.

The last mile: add driver to FastArduino project!

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:

  • thank other people who provided FastArduino open source library to you
  • feel part of the community
  • get feedback on your work, potentially allowing it to be further improved
  • share your work with the rest of the world

However, like for a marathon, the last mile can be difficult! In order to run this last mile, you will have to:

  • first accept FastArduino Apache License 2.0 for your contribution, or discuss with FastArduino owner for another one, if compatible
  • follow FastArduino coding guidelines: this might impose some code rewrite or reformatting
  • add API documentation with doxygen: this is mandatory for all public methods, and advised for protected ones.
  • add one (or more) usage example and integrate it in the 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.
  • optionally develop a tutorial for this device
  • prepare and propose a PR to FastArduino project

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.