Communicate via I²C Advanced

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:

I²C: SDA/SCL Wiring

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 1

	//...
	import minihil.platform.i2c.AI2CMaster2Adapter
	import minihil.platform.adapters.i2cbus.AI2CBus3Adapter 2

	ActorClass Application {
		Structure {
			//	  ...
			ActorRef i2c_master2: AI2CMaster2Adapter
			ActorRef i2c_slave: AI2CBus3Adapter 3
		}
	}
}
1 Imports for later
2 Add the appropriate imports for the adapters.
3 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.

I2CMasterStructure
I²C Master Example Structure
RoomModel MiniHilProject {
	//...
	import minihil.platform.i2c.AI2CMaster2Adapter
	import minihil.platform.adapters.i2cbus.AI2CBus3Adapter
	import minihil.platform.i2c.PI2CMasterCtrl 1

	ActorClass Application {
		Structure {
			//	  ...
			ActorRef i2c_master2: AI2CMaster2Adapter
			ActorRef master_sim: AI2CMasterSim 2
			Binding master_sim.ctrl and i2c_master2.fct 3

			ActorRef i2c_slave: AI2CBus3Adapter
		}
	}

	ActorClass AI2CMasterSim {
		Interface {
			conjugated Port ctrl: PI2CMasterCtrl 4
		}
		Structure {
			external Port ctrl
			SAP timer: PTimer 5
			SAP logger: PLogger 6
		}
		Behavior {
            //...
			}
		}
	}
1 Import the required port for communication between simulator and adapter.
2 I²C master simulator instance.
3 Connect simulator and adapter.
4 Port for communication between master and simulator.
5 To be able to do periodic actions we need the timer service
6 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:

I2CMasterBehavior
I²C Master Example Behavior

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 "{}" 1

	ActorClass Application {
		Structure {
			//	  ...
			ActorRef i2c_slave: AI2CBus3Adapter
			ActorRef slave_sim: AI2CSlaveSim
			Binding slave_sim.i2cBus and i2c_slave.conf 2
		}
	}

	ActorClass AI2CSlaveSim {
		Interface {
			conjugated Port i2cBus: PI2CBusConfig 3
		}
		Structure {
			usercode1 '''
				#include "HAL.h"
				#include "I2C.h" 4

				// 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; 5
			'''
			/* 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: {6
								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 {7
						// 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 8
			SAP logger: PLogger
			SAP timer: PTimer
		}
        Behavior {
            //...
			}
		}
	}
1 Import adapter for I²C bus, necessary port for communicating between slave sim and bus and type for slave context.
2 Connect slave sim to bus adapter.
3 Port for communication between simulator and adapter.
4 Include libraries for I²C hardware.
5 Define struct for storing communication data parameters.
6 Transmitting: put data in buffer and send.
7 Receiving: store data to struct and mark that data has been read.
8 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:

I2CSlaveBehavior
I²C Slave Example Behavior

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:

I²C example output
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();'''
				}
			}
		}
	}
}