Communicate via I²C 
Motivation
In some cases, a device-under-test requires I²C to communicate with the simulator for example to simulate peripherals.
This tutorial will show how to configure an I²C controller(master) and target(slave) and
how to implement a correct communication by sending a simple "HELLO" message using I²C.
Pre-requisites
For the miniHIL to communicate with itself, we need to connect the master and slave’s SDA and SCL pins. For this
tutorial, these are the correct pins:
SDA: PF0 → PH8 SCL: PF1 → PH7
Please connect them like in the image below:

To prepare the SW create a new project as described in the "Creating a new project" How-To.
Finally, to be able to see some output, make sure you can log as described in here. Note that in this case you only need the USB connection. Log messages will be generated by the I²C tester below.
Instantiate the I²C in SW
To run I²C, two actors are required: One for the adapter, which represents the I²C hardware
and one for the simulator, which represents our testing code.
This is done to accomodate different I²C implementations and
requires the user to implement i2cCallbacks in C. We will first only instantiate the actors
for the adapters.
RoomModel MiniHilProject {
import etrice.api.timer.PTimer
import etrice.api.logger.PLogger
import etrice.api.types.uint16
import etrice.api.types.int16
//...
import minihil.platform.i2c.AI2CMaster2Adapter
import minihil.platform.adapters.i2cbus.AI2CBus3Adapter
ActorClass Application {
Structure {
// ...
ActorRef i2c_master2: AI2CMaster2Adapter
ActorRef i2c_slave: AI2CBus3Adapter
}
}
}
![]() |
Imports for later |
![]() |
Add the appropriate imports for the adapters. |
![]() |
Instantiate the adapters. |
The AI2CMaster2Adapter and AI2CBus3Adapter are an abstractions for the miniHIL hardware, freeing you from the specifics of pin assignment and such.
In this case the adapters are configured to use the PF0/PF1 and PH8/PH7 pins respectively for SDA/SCL. If you are interested
in details set the cursor on AI2CMaster2Adapter or AI2CBus3Adapter and press F3.
Creating the master sim
To be able to communicate with the adapter, we need to implement a simulator that tells the adapter when to read and
write. First we will create a hull for the simulator and connect it to the adapter as shown below.
RoomModel MiniHilProject {
//...
import minihil.platform.i2c.AI2CMaster2Adapter
import minihil.platform.adapters.i2cbus.AI2CBus3Adapter
import minihil.platform.i2c.PI2CMasterCtrl
ActorClass Application {
Structure {
// ...
ActorRef i2c_master2: AI2CMaster2Adapter
ActorRef master_sim: AI2CMasterSim
Binding master_sim.ctrl and i2c_master2.fct
ActorRef i2c_slave: AI2CBus3Adapter
}
}
ActorClass AI2CMasterSim {
Interface {
conjugated Port ctrl: PI2CMasterCtrl
}
Structure {
external Port ctrl
SAP timer: PTimer
SAP logger: PLogger
}
Behavior {
//...
}
}
}
![]() |
Import the required port for communication between simulator and adapter. |
![]() |
I²C master simulator instance. |
![]() |
Connect simulator and adapter. |
![]() |
Port for communication between master and simulator. |
![]() |
To be able to do periodic actions we need the timer service |
![]() |
We will need to do some logging to see what is going on |
Adding behavior to the master simulator
Currently no communications are occuring. First we need to tell the master when and what to send. For this, we create the following state machine:
After initializing, the master alternates between running and writing state on a timer, where in the tr0 transition, we send
a message to the adapter with our data. This results in the following code:
ActorClass AI2CMasterSim {
//...
Behavior {
StateMachine {
State running
Transition init0: initial -> running {
action '''
timer.startTimer(1000);
'''
}
Transition tr0: running -> writing {
triggers {
<timeout: timer>
}
action '''
// I2C request requires address and buffer length
DI2CWriteRequest req;
req.address = 0x10;
req.data[0] = 'H';
req.data[1] = 'E';
req.data[2] = 'L';
req.data[3] = 'L';
req.data[4] = 'O';
req.data[5] = 0x00;
req.length = 6;
ctrl.write(&req);
test.write(&req);
'''
}
State writing
Transition tr1: writing -> writing {
triggers {
<nack: ctrl>
}
action '''logger.log("NACK received");
test.nack();'''
}
Transition tr2: writing -> running {
triggers {
<writeComplete: ctrl>
}
action '''logger.log("Write complete");
test.writeComplete();'''
}
Transition tr3: writing -> writing {
triggers {
<error: ctrl>
}
action '''logger.log("I2C Error");
test.error();'''
}
}
}
}
Creating the slave sim
Similar to the master sim, we need to create a simulator to communicate with the I²C bus adapter, register its address
and process messages it receives. Again we’ll create a hull for the simulator and connect it to the adapter to begin
with. This time however we will also implement the i2cCallback in the structure to define how the slave will handle transfering and
receiving data from the bus. In our case, we use the following implementation:
RoomModel MiniHilProject {
//...
import minihil.platform.adapters.i2cbus.AI2CBus3Adapter
import minihil.platform.adapters.i2cbus.PI2CBusConfig
// make user defined i2cContext available in room
ExternalType AI2CSlaveSimContext -> "AI2CSlaveSimContext" default "{}"
ActorClass Application {
Structure {
// ...
ActorRef i2c_slave: AI2CBus3Adapter
ActorRef slave_sim: AI2CSlaveSim
Binding slave_sim.i2cBus and i2c_slave.conf
}
}
ActorClass AI2CSlaveSim {
Interface {
conjugated Port i2cBus: PI2CBusConfig
}
Structure {
usercode1 '''
#include "HAL.h"
#include "I2C.h"
// user defined context for passing data between usercode and room code
typedef struct AI2CSlaveSimContext {
uint16_t address;
uint8_t data[8];
uint8_t isDatafresh;
uint8_t regAddr;
} AI2CSlaveSimContext;
'''
/* i2cCallback
* called by the driver during communication
* @param type: communication status. Complete, address match, stop
* @param context: data from driver. Buffer, buffer length, transfer size, transfer direction
* @param userContext: user defined context for passing data to room code. userContext is mutable, pointer is immutable
*/
usercode3 '''
static void i2cCallback(const I2CSlaveCallbackType type, const I2CTransferDirection direction, uint8_t * const packageBuffer, I2CContext * const context, void * const userContext) {
AI2CSlaveSimContext *state = (AI2CSlaveSimContext*) userContext;
if (direction == I2CTransferDirection_RX) {
// master rx requested -> we have to transmit
switch (type) {
case I2CSlaveCallbackType_TRANSFER_CPL: // intentionally no break here. Same behavior as addr match
case I2CSlaveCallbackType_ADDR_MATCH: {
for (uint8_t i = 0; i < context->bufferLength; i++) {
context->buffer[i] = i;
}
// transmit context buffer
I2CSlave_startTransmit(context, context->bufferLength);
break;
}
default: {
// send nack
I2CSlave_endTransfer(context);
}
}
} else {
// master tx requested -> we are being written to, just receive whatever comes but do not check anything
switch (type) {
case I2CSlaveCallbackType_ADDR_MATCH: {
// start receiving command byte
I2CSlave_startReceive(context, 6);
break;
}
case I2CSlaveCallbackType_TRANSFER_CPL: {
for(int i = 0; i < 6; i++)
state->data[i] = context->buffer[i];
state->isDataFresh = 1;
// receive stop message
I2CSlave_startReceive(context, 1);
break;
}
default: {
// send nack
I2CSlave_endTransfer(context);
}
}
}
}
'''
external Port i2cBus
Attribute currentAddress: uint16 = "0x10"
Attribute slaveRegistrationID: int16
// userContext from i2cCallback is copied to i2cContext. i2cContext is used to access data in room code
Attribute i2cContext: AI2CSlaveSimContext
SAP logger: PLogger
SAP timer: PTimer
}
Behavior {
//...
}
}
}
![]() |
Import adapter for I²C bus, necessary port for communicating between slave sim and bus and type for slave context. |
![]() |
Connect slave sim to bus adapter. |
![]() |
Port for communication between simulator and adapter. |
![]() |
Include libraries for I²C hardware. |
![]() |
Define struct for storing communication data parameters. |
![]() |
Transmitting: put data in buffer and send. |
![]() |
Receiving: store data to struct and mark that data has been read. |
![]() |
Define variables behavior. |
Adding behavior to the slave simulator
Now that we have defined how the actual data transfer between master and slave occurs, we still need to tell the slave
what to do with the data it receives and how to register itself to the bus. This can be done with the followig
state machine:
The init transition tells the bus what the slave’s current address is and gives its i2cCallback. After successful registration,
the slave sim moves into the active state where tr0 periodically logs data it has received. If there was an error while
registering, the slave sim moves to the error state unconfigured. This behavior results in the following code:
ActorClass AI2CSlaveSim { //… Behavior { // slave configuration Operation registerSlave() ''' DI2CSlaveSimConfig conf; conf.callback = i2cCallback; // set user defined i2cCallback called by driver conf.address = currentAddress; // set slave address and address length if (currentAddress > 0x7F) { conf.addressLength = I2CAddressLength_BITS_10; } else { conf.addressLength = I2CAddressLength_BITS_7; } conf.userContext = (void*)&i2cContext; // bind i2cContext and userContext
i2cBus.registerBusSlave(&conf);
'''
StateMachine {
/*
* general flow: set address and register slave -> wait for confirmation -> process data
*/
State unconfigured
State registeringSlave
Transition init0: initial -> registeringSlave {
action '''
timer.startTimer(100);
currentAddress = 0x10;
registerSlave();
logger.log("[AI2CLongResponseSlaveSim] Registering slave");
'''
}
Transition tr1: registeringSlave -> unconfigured {
triggers {
<noFreeSlots: i2cBus>
}
action '''
slaveRegistrationID = -1;
logger.log("[AI2CLongResponseSlaveSim] Slave registration failed");
'''
}
State active
Transition tr2: registeringSlave -> active {
triggers {
<slaveRegistered: i2cBus>
}
action '''
slaveRegistrationID = transitionData;
logger.logF("[AI2CLongResponseSlaveSim] Slave registered successully. Address: %02X", currentAddress);
'''
}
Transition tr0: active -> active {
triggers {
<timeout: timer>
}
action '''
if(i2cContext.isDataFresh){
if(i2cContext.isDataFresh){
i2cContext.isDataFresh = 0;
logger.logF("i2c data: %x, %x, %x, %x, %x, %x", i2cContext.data[0], i2cContext.data[1], i2cContext.data[2], i2cContext.data[3], i2cContext.data[4], i2cContext.data[5]);
logger.logF("Complete message: %s", &i2cContext.data[0]);
test.receivedString(&i2cContext.data[0]);
}
'''
}
}
}
}
Running the example
Once you have flashed and started the target, you should see an output like this in your terminal:
Write complete i2c data[0] = 48 i2c data[1] = 45 i2c data[2] = 4c i2c data[3] = 4c i2c data[4] = 4f i2c data[5] = 0 Complete message: HELLO ------------------------ Write complete i2c data[0] = 48 i2c data[1] = 45 i2c data[2] = 4c i2c data[3] = 4c i2c data[4] = 4f i2c data[5] = 0 Complete message: HELLO ------------------------
Summary
-
Instatiate an I²C adapter
-
Where to define master and slave behavior
-
Connecting I²C simulators to adapters
See also
Complete example file
RoomModel MiniHilProject {
import etrice.api.timer.PTimer
import etrice.api.logger.PLogger
import etrice.api.types.uint16
import etrice.api.types.int16
// imports for I²C adapters
import minihil.platform.i2c.AI2CMaster2Adapter
import minihil.platform.adapters.i2cbus.AI2CBus3Adapter
import minihil.platform.adapters.i2cbus.PI2CBusConfig
import minihil.platform.i2c.PI2CMasterCtrl
// make user defined i2cContext available in room
ExternalType AI2CSlaveSimContext -> "AI2CSlaveSimContext" default "{}"
ActorClass Application {
Structure {
ActorRef master_sim: AI2CMasterSim
ActorRef i2c_master2: AI2CMaster2Adapter
Binding master_sim.ctrl and i2c_master2.fct
ActorRef i2c_slave: AI2CBus3Adapter
ActorRef slave_sim: AI2CSlaveSim
Binding slave_sim.i2cBus and i2c_slave.conf
ActorRef i2c_tester: MainTestActor
Binding master_sim.test and i2c_tester.testMaster
Binding slave_sim.test and i2c_tester.testSlave
}
}
ActorClass AI2CSlaveSim {
Interface {
conjugated Port i2cBus: PI2CBusConfig
Port test: PI2CSlaveTest
}
Structure {
usercode1 '''
#include "HAL.h"
#include "I2C.h"
// user defined context for passing data between usercode and room code
typedef struct AI2CSlaveSimContext {
uint16_t address;
uint8_t data[8];
uint8_t isDataFresh;
uint8_t regAddr;
} AI2CSlaveSimContext;
'''
/* i2cCallback
* called by the driver during communication
* @param type: communication status. Complete, address match, stop
* @param context: data from driver. Buffer, buffer length, transfer size, transfer direction
* @param userContext: user defined context for passing data to room code. userContext is mutable, pointer is immutable
*/
usercode3 '''
static void i2cCallback(const I2CSlaveCallbackType type, const I2CTransferDirection direction, uint8_t * const packageBuffer, I2CContext * const context, void * const userContext) {
AI2CSlaveSimContext *state = (AI2CSlaveSimContext*) userContext;
if (direction == I2CTransferDirection_RX) {
// master rx requested -> we have to transmit
switch (type) {
case I2CSlaveCallbackType_TRANSFER_CPL: // intentionally no break here. Same behavior as addr match
case I2CSlaveCallbackType_ADDR_MATCH: {
for (uint8_t i = 0; i < context->bufferLength; i++) {
context->buffer[i] = i;
}
// transmit context buffer
I2CSlave_startTransmit(context, context->bufferLength);
break;
}
default: {
// send nack
I2CSlave_endTransfer(context);
}
}
} else {
// master tx requested -> we are being written to, just receive whatever comes but do not check anything
switch (type) {
case I2CSlaveCallbackType_ADDR_MATCH: {
// start receiving command byte
I2CSlave_startReceive(context, 6);
break;
}
case I2CSlaveCallbackType_TRANSFER_CPL: {
for(int i = 0; i < 6; i++)
state->data[i] = context->buffer[i];
state->isDataFresh = 1;
// receive stop message
I2CSlave_startReceive(context, 1);
break;
}
default: {
// send nack
I2CSlave_endTransfer(context);
}
}
}
}
'''
external Port i2cBus
external Port test
Attribute currentAddress: uint16 = "0x10"
Attribute slaveRegistrationID: int16
// userContext from i2cCallback is copied to i2cContext. i2cContext is used to access data in room code
Attribute i2cContext: AI2CSlaveSimContext
SAP logger: PLogger
SAP timer: PTimer
}
Behavior {
// slave configuration
Operation registerSlave() '''
DI2CSlaveSimConfig conf;
conf.callback = i2cCallback; // set user defined i2cCallback called by driver
conf.address = currentAddress; // set slave address and address length
if (currentAddress > 0x7F) {
conf.addressLength = I2CAddressLength_BITS_10;
} else {
conf.addressLength = I2CAddressLength_BITS_7;
}
conf.userContext = (void*)&i2cContext; // bind i2cContext and userContext
i2cBus.registerBusSlave(&conf);
'''
StateMachine {
/*
* general flow: set address and register slave -> wait for confirmation -> process data
*/
State unconfigured
State registeringSlave
Transition init0: initial -> registeringSlave {
action '''
timer.startTimer(100);
currentAddress = 0x10;
registerSlave();
logger.log("[AI2CLongResponseSlaveSim] Registering slave");
'''
}
Transition tr1: registeringSlave -> unconfigured {
triggers {
<noFreeSlots: i2cBus>
}
action '''
slaveRegistrationID = -1;
logger.log("[AI2CLongResponseSlaveSim] Slave registration failed");
'''
}
State active
Transition tr2: registeringSlave -> active {
triggers {
<slaveRegistered: i2cBus>
}
action '''
slaveRegistrationID = transitionData;
logger.logF("[AI2CLongResponseSlaveSim] Slave registered successully. Address: %02X", currentAddress);
'''
}
Transition tr0: active -> active {
triggers {
<timeout: timer>
}
action '''
if(i2cContext.isDataFresh){
i2cContext.isDataFresh = 0;
logger.logF("i2c data[0] = %x", i2cContext.data[0]);
logger.logF("i2c data[1] = %x", i2cContext.data[1]);
logger.logF("i2c data[2] = %x", i2cContext.data[2]);
logger.logF("i2c data[3] = %x", i2cContext.data[3]);
logger.logF("i2c data[4] = %x", i2cContext.data[4]);
logger.logF("i2c data[5] = %x", i2cContext.data[5]);
logger.logF("Complete message: %s", &i2cContext.data[0]);
logger.log("------------------------");
test.receivedString(&i2cContext.data[0]);
}
'''
}
}
}
}
ActorClass AI2CMasterSim {
Interface {
conjugated Port ctrl: PI2CMasterCtrl
Port test: PI2CMasterTest
}
Structure {
external Port ctrl
external Port test
SAP timer: PTimer
SAP logger: PLogger
}
Behavior {
StateMachine {
State running
Transition init0: initial -> running {
action '''
timer.startTimer(1000);
'''
}
Transition tr0: running -> writing {
triggers {
<timeout: timer>
}
action '''
// I2C request requires address and buffer length
DI2CWriteRequest req;
req.address = 0x10;
req.data[0] = 'H';
req.data[1] = 'E';
req.data[2] = 'L';
req.data[3] = 'L';
req.data[4] = 'O';
req.data[5] = 0x00;
req.length = 6;
ctrl.write(&req);
test.write(&req);
'''
}
State writing
Transition tr1: writing -> writing {
triggers {
<nack: ctrl>
}
action '''logger.log("NACK received");
test.nack();'''
}
Transition tr2: writing -> running {
triggers {
<writeComplete: ctrl>
}
action '''logger.log("Write complete");
test.writeComplete();'''
}
Transition tr3: writing -> writing {
triggers {
<error: ctrl>
}
action '''logger.log("I2C Error");
test.error();'''
}
}
}
}
}