In a previous post the sending of a single byte using I2C between a Raspberry Pi (master) running a Python 3.7 script and a microcontroller using Arduino libraries was described.  In this post a more useful template with multiple byte transfer is presented that demonstrates control of GPIO, ADC, PWM, and stored data.

 

Introduction

 

I have been thinking about building a robot that has Image Recognition using a Raspberry Pi but that is a full time task for a Raspberry Pi.  So let's offload the Pi a bit.

Robot with Raspberry Pi and Microcontroller connected by I2C

A microcontroller (or microcontrollers) might be connected to the Raspberry Pi with I2C.  This would give the Raspberry Pi additional capability, such as reading analog values, and can also offload tasks with real time requirements.  The previous post gives a brief introduction to I2C and both a Python script for the Raspberry Pi and Arduino code for communication of a single byte.  If you are not familiar with I2C it has some information and links which might prove useful.

 

In this post a template for sending and requesting data with multiple bytes is explored.  The template demonstrates writing to the DAC, GPIO, PWM and LED.  Reading the ADC and GPIO on the microcontroller are also demonstrated.  All with only two pins and ground from the Raspberry Pi.

Block Diagram

 

There is related information on the internet but much of it is written for Python 2.7 and in the case of Arduino early versions before the Wire library was changed.  They also do not go into multi-byte communication.  A more comprehensive post for Arduino that I can recommend is this one by Nick Gammon.  The approach used here is based upon it but demonstrates use of more of the capabilities of the microcontroller and also how to communicate with a Raspberry Pi.

 

The components required are a Raspberry Pi and a microcontroller that can use the Arduino Wire Library and has GPIO, ADC, and PWM capability.  In the demonstration that follows a Raspberry Pi 4 and an Adafruit Feather M4 Express were used. Four resistors were used to make a simple resistor ladder to provide differing voltages for the ADC on the Feather.  A jumper was used to simulate a switch.   A closeup of the Raspberry Pi connected to the Feather with resistor ladder is shown below.

Closeup of Arduino Connected to Raspberry Pi

An oscilloscope was used to check that PWM and GPIO output were functioning as desired.  The complete setup is shown below.

Test Setup for Raspberry Pi / Arduino I2C Communication

 

Adafruit Feather and Arduino Code

 

Connection between the Raspberry Pi and the Feather is as follows:

  • SDA <--> SDA
  • SCL <--> SCL
  • GND <--> GND

 

The following Arduino code can be expanded or reduced as desired and demonstrates GPIO, ADC, PWM, and transfer of data.   Note that code for controlling the DAC on the Feather is provided but it will not function properly if the other ADCs are being used.  You are free to use it as desired but keep in mind there is limited to no error checking and my intended use is as a hobbyist and not in critical applications.

/* Arduino_I2C_uC
 * Demonstrates Arduino I2C Slave
 * Written and tested on Adafruit Feather M0 Express
 * 
 * Useful Link:  http://www.gammon.com.au/forum/?id=10896
 * 
 * PWM working only at 8 bit?
 * DAC not working
 * Tested on Adafruit M0 Express and M4 Express
 * 
 * fmilburn    Dec 2019
 */
#include <Wire.h>
const byte MY_ADDRESS = 0x04;
const byte VERSION = 0x01;
const int  NUM_REGISTERS = 13;
const byte MAX_REC_BYTES = 3;      // Maximum received bytes allowed

enum {
//  I2C accepted commands and bytes read / written; Pin marked on silkscreen
//  Command       Offset     Pin  Description
    // Don't use  0
    CMD_W2_DAC =  1,    //   A0   Write 2 bytes to DAC (pin A0) ** Not working
    CMD_R2_A1  =  2,    //   A1   Read 2 bytes from ADC pin A1
    CMD_R2_A2  =  3,    //   A2   Read 2 bytes from ADC pin A1
    CMD_R2_A3  =  4,    //   A3   Read 2 bytes from ADC pin A3
    CMD_R2_A4  =  5,    //   A4   Read 2 bytes from ADC pin A4 
    CMD_R2_A5  =  6,    //   A5   Read 2 bytes from ADC pin A5
    CMD_W1_O5  =  7,    //   5    Write output 0/1 to pin 5
    CMD_W1_O6  =  8,    //   6    Write output 0/1 to pin 6
    CMD_R1_I9 =   9,    //   9    Read input 0/1 from pin 9
    CMD_W2_PWM10=10,    //   10   Write 0-256 PWM to pin 10
    CMD_W1_LED = 11,    //   13   Write 0/1 to on-board LED
    CMD_R1_ID  = 12     //   -    Read bytes device ID and version
};
// structure with the micro controller pins for each command
struct i2cRegister{
  byte cmd;               // command index
  byte pin;               // pin number marked on silkscreen
};
struct i2cRegister uC[NUM_REGISTERS];
void setup() {
  
  setupRegisters();
  setupBoard();
  Wire.begin (MY_ADDRESS);
  Wire.onRequest(requestEvent);
  Wire.onReceive(receiveEvent);
}
void loop(){
  // everything done in interrupts
}
void setupRegisters(){
  
  uC[1].cmd = CMD_W2_DAC;      // Write 2 bytes to DAC (pin A0)
  uC[1].pin = A0;
  
  uC[2].cmd = CMD_R2_A1;       // Read 2 bytes from ADC pin A2
  uC[2].pin = A1;
  
  uC[3].cmd = CMD_R2_A2;       // Read 2 bytes from ADC pin A2
  uC[3].pin = A2;
  
  uC[4].cmd = CMD_R2_A3;       // Read 2 bytes from ADC pin A3
  uC[5].pin = A3;
  
  uC[5].cmd = CMD_R2_A4;       // Read 2 bytes from ADC pin A4
  uC[5].pin = A4;
  
  uC[6].cmd = CMD_R2_A5;       // Read 2 bytes from ADC pin A5
  uC[6].pin = A5;
  
  uC[7].cmd = CMD_W1_O5;       // Write 0/1 to pin 5
  uC[7].pin = 5;
  
  uC[8].cmd = CMD_W1_O6;       // Write 0/1 to pin 6
  uC[8].pin = 6;
  
  uC[9].cmd = CMD_R1_I9;       // Read 0/1 from pin 9
  uC[9].pin = 9;
  
  uC[10].cmd = CMD_W2_PWM10;   // Write 0-255 byte PWM to pin 10
  uC[10].pin = 10;              
  
  uC[11].cmd = CMD_W1_LED;     // Write 0/1 to on-board LED
  uC[11].pin = 13;
  
  uC[12].cmd = CMD_R1_ID;      // Read 2 bytes device ID
  uC[12].pin = 0;
}
void setupBoard(){
  // DAC not working
  
  // ADC - board has higher resolution
  // But seems to work best at 8 bit
  
  // Output pins
  pinMode(uC[CMD_W1_O5].pin, OUTPUT);
  digitalWrite(uC[CMD_W1_O5].pin, LOW);
  pinMode(uC[CMD_W1_O6].pin, OUTPUT);
  digitalWrite(uC[CMD_W1_O6].pin, LOW);
  // Input pins
  pinMode(uC[CMD_R1_I9].pin, INPUT_PULLUP);
  // LED
  pinMode(uC[CMD_W1_LED].pin, OUTPUT);
  digitalWrite(uC[CMD_W1_LED].pin, HIGH);    // initialize LED high
}
void controlDAC(byte pin, byte msb, byte lsb){  
   int DACvalue = (int) (msb << 8) + (int) lsb;
   analogWrite(pin, DACvalue);
}
void controlPWM(byte pin, byte msb, byte lsb){
   int PWMvalue = (int) (msb << 8) + (int) lsb;
   analogWrite(pin, PWMvalue);
}
void controlOutput(byte pin, byte state) {
   digitalWrite(pin, state);
}
void returnInput(byte pin) {
   byte input = (byte) digitalRead(pin);
   Wire.write(input);
}
void returnADC(byte pin){
   // response must be in a buffer
   byte buf[2];
   int ADCvalue = analogRead(pin);
   buf[0] = (byte) ((ADCvalue & 0xFF00) >> 8);
   buf[1] = (byte) (ADCvalue & 0xFF);
   Wire.write (buf, sizeof buf);
}
void requestEvent(){
  byte command = Wire.read ();  
  
  switch(command){
    case CMD_R2_A1:
      returnADC(uC[CMD_R2_A1].pin);
      break;
    case CMD_R2_A2:
      returnADC(uC[CMD_R2_A2].pin);
      break;
    case CMD_R2_A3:
      returnADC(uC[CMD_R2_A3].pin);
      break;
    case CMD_R2_A4:
      returnADC(uC[CMD_R2_A4].pin);
      break;
    case CMD_R2_A5:
      returnADC(uC[CMD_R2_A5].pin);
      break;
    case CMD_R1_I9:
      returnInput(uC[CMD_R1_I9].pin);
      break;
    case CMD_R1_ID:
      byte buf[MAX_REC_BYTES];
      buf[0] = MY_ADDRESS;
      buf[1] = VERSION;
      Wire.write (buf, sizeof buf);
      break;
    default:
      break;
  }
}
void receiveEvent(int bytesReceived) {
  // Read I2C bytes received into a buffer
  byte buf[MAX_REC_BYTES];
  int i = 0;
  for (i = 0; i < bytesReceived; i++) {
    if (i < MAX_REC_BYTES) {              // check if above maximum
      buf[i] = Wire.read();
    }
    else {
      Wire.read();                        // throw away extra
    }
  }
  // Execute the command contained in buf[0]
  switch (buf[0]) {
    case CMD_W2_DAC:
      controlDAC(uC[CMD_W2_DAC].pin, buf[1], buf[2]);
      break;
    case CMD_W1_O5:
      controlOutput(uC[CMD_W1_O5].pin, buf[1]);
      break;
    case CMD_W1_O6:
      controlOutput(uC[CMD_W1_O6].pin, buf[1]);
      break;
    case CMD_W2_PWM10:                  
      controlPWM(uC[CMD_W2_PWM10].pin, buf[1], buf[2]);
      break;
    case CMD_W1_LED:
      controlOutput(uC[CMD_W1_LED].pin, buf[1]);
      break;
    default:
      break;
  }
}

 

An arbitrary version number is assigned on line 16 that is used in this example to demonstrate how data can be transferred.  The number of "registers" in the I2C device is on line 16. The Command list in the enumeration below corresponds to the number of registers.  The maximum number of bytes that will ever be received from the master is given in line 17.  The following code is found at line 19 and is the key to understanding much of the program.

enum {

//  I2C accepted commands and bytes read / written; Pin marked on silkscreen
//  Command       Offset     Pin  Description
    // Don't use  0
    CMD_W2_DAC =  1,    //   A0   Write 2 bytes to DAC (pin A0) ** Not working
    CMD_R2_A1  =  2,    //   A1   Read 2 bytes from ADC pin A1
    CMD_R2_A2  =  3,    //   A2   Read 2 bytes from ADC pin A1
    CMD_R2_A3  =  4,    //   A3   Read 2 bytes from ADC pin A3
    CMD_R2_A4  =  5,    //   A4   Read 2 bytes from ADC pin A4 
    CMD_R2_A5  =  6,    //   A5   Read 2 bytes from ADC pin A5
    CMD_W1_O5  =  7,    //   5    Write output 0/1 to pin 5
    CMD_W1_O6  =  8,    //   6    Write output 0/1 to pin 6
    CMD_R1_I9 =   9,    //   9    Read input 0/1 from pin 9
    CMD_W2_PWM10=10,    //   10   Write 0-256 PWM to pin 10
    CMD_W1_LED = 11,    //   13   Write 0/1 to on-board LED
    CMD_R1_ID  = 12     //   -    Read bytes device ID and version
};

 

This enumeration describes the 12 commands that are available, where they are located, what pin they write or read from, and the number of bytes used.

  • CMD is short for command
  • The enumeration is equal to the "offset" or location of the command in the switch statements used to control program flow
  • R or W (read or write) and the number of bytes which will be read or written.  Next is an abbreviation for the pin or device on the microcontroller being controlled.
  • Finally there is a description of the pin being accessed and the number of bytes transmitted using I2C

 

The struct and array located at lines 38-41 assigns pins to their respective commands.

 

As in the previous post functions that handle the interrupts are declared and all action takes place in interrupts.  Simple functions were set up to handle the requests and activities starting at line 108.

  • controlDAC: The DAC requires two bytes if in 10 bit mode which are sent over by the master.  This function converts the two bytes to an integer and makes a call to analogWrite which sets the voltage on the pin.  Unfortunately subsequent requests to other pins may disrupt the output.  It is not known if this a coding issue in Arduino or a hardware issue with the microcontroller but it should be checked before use.
  • controlPWM:  PWM can be 8 or 10 (or 12 bit on some microcontrollers).  Here 8 bit is being used but higher resolution allowed for.
  • controlOutput:  Controlling the output to a pin or a single color LED requires only one byte with a 0 or 1 to indicate the state
  • returnInput:  This function returns the current state of a pin as a single byte to the master over I2C.  The method used is the same as described in the previous post
  • returnADC:  This function returns the two bytes capable of being read by the ADC to the master over I2C.  To do this it is necessary to split the integer value read into the most significant byte (MSB) and least significant byte (LSB).  They are then placed into a buffer with the MSB first.  Writing over I2C is done in Line 129 with the buffer followed by the size of the buffer and Wire.write does the rest.

 

The requestEvent interrupt occurs when the master requests data.  The command is read in as a byte and then a switch statement directs the request to the proper case.  The requested data is then sent.

 

The receiveEvent interrupt is only a bit more complicated. 

  // Read I2C bytes received into a buffer
  byte buf[MAX_REC_BYTES];
  int i = 0;
  for (i = 0; i < bytesReceived; i++) {
    if (i < MAX_REC_BYTES) {              // check if above maximum
      buf[i] = Wire.read();
    }
    else {
      Wire.read();                        // throw away extra
    }
  }

 

The bytes are read into a buffer until I2C transfer is completed.  If the number of bytes is greater than the maximum anticipated, the extra bytes are discarded.  The first byte contains the command and that is used in the switch statement to direct flow to the proper case.  The remaining bytes in the buffer are then passed to the proper function along with the pin number.

 

Python Script

 

The rough Python script below can be used to test the microcontroller using I2C.

'''
i2c_v1.py
Simple Python 3 script to test I2C
Tested on Raspberry Pi 4 B+ and Adafruit Feather M4 Express
'''
import smbus
import time
import sys
bus = smbus.SMBus(1)
address = 0x04              # Arduino I2C Address
def main():
    
    PWMpercent = 50         # PWM pin 11
    data6 = [0x00]          # Pin 6 GPIO 
    
    while 1:
      '''
      #1  Write DAC
      Voltage = 2
      ADC = int(Voltage / 3.3 * 1023)
      msb = ((ADC & 0xFF00)  >> 8)
      lsb = (ADC & 0xFF)
      offset = 1
      DACdata = [msb, lsb]
      bus.write_i2c_block_data(address, offset, DACdata)
      print ("%s %5.2f" % ("Write A0:", Voltage))
      time.sleep(5)
      '''
      
      #2 Read A1
      offset = 2
      numBytes = 2    
      block = bus.read_i2c_block_data(address, offset, numBytes)
      ADCvalue = (block[0]<<8) + block[1]
      Voltage = ADCvalue / 1023 * 3.3
      print("%s %7.3f" % ("Read A1:", Voltage))
        
      #3 Read A2
      offset = 3
      numBytes = 2    
      block = bus.read_i2c_block_data(address, offset, numBytes)
      ADCvalue = (block[0]<<8) + block[1]
      Voltage = ADCvalue / 1023 * 3.3
      print("%s %7.3f" % ("Read A2:", Voltage))
        
      #4 Read A3
      offset = 4
      numBytes = 2    
      block = bus.read_i2c_block_data(address, offset, numBytes)
      ADCvalue = (block[0]<<8) + block[1]
      Voltage = ADCvalue / 1023 * 3.3
      print("%s %7.3f" % ("Read A3:", Voltage))
      
      #5 Read A4
      offset = 5
      numBytes = 2    
      block = bus.read_i2c_block_data(address, offset, numBytes)
      ADCvalue = (block[0]<<8) + block[1]
      Voltage = ADCvalue / 1023 * 3.3
      print("%s %7.3f" % ("Read A4:", Voltage))
      
      #6 Read A5
      offset = 6
      numBytes = 2    
      block = bus.read_i2c_block_data(address, offset, numBytes)
      ADCvalue = (block[0]<<8) + block[1]
      Voltage = ADCvalue / 1023 * 3.3
      print("%s %7.3f" % ("Read A5:", Voltage))
      
      #7 Write O5
      offset = 7
      data = [0x01]
      bus.write_i2c_block_data(address, offset, data)
      print ("%s %d" % ("Write 5:  ",data[0]))
      
      #8 Write O6
      offset = 8
      bus.write_i2c_block_data(address, offset, data6)
      print ("%s %d" % ("Write 6:  ",data6[0]))
      
      #9 Read I9
      offset = 9
      numBytes = 1
      block = bus.read_i2c_block_data(address, offset, numBytes)
      print("%s %2d" % ("Read 9:  ", block[0]))
      
      #10 Write PWM
      PWM = int(PWMpercent / 100 * 255)
      msb = (PWM & 0xFF00)  >> 8
      lsb = PWM & 0xFF
      offset = 10
      data = [msb, lsb]
      bus.write_i2c_block_data(address, offset, data)
      print ("%s %2d" % ("Write PWM:", PWMpercent))
      # time.sleep(0.001)
      
      #11 Write LED
      offset = 11
      # turn LED on
      data = [0x01]
      bus.write_i2c_block_data(address, offset, data)
      print ("%s %d" % ("Write LED:",data[0]))
      time.sleep(.5)
      # turn LED off 
      data = [0x00]
      bus.write_i2c_block_data(address, offset, data)
      print ("%s %d" % ("Write LED:", data[0]))
      
      #12 Read ID
      offset = 12
      numBytes = 2
      block = bus.read_i2c_block_data(address, offset, numBytes)
      print("Address:  ", block[0])
      print("%s %d" % ("version:  ", block[1]))
      print ("")
      
      print("Enter p to change PWM")
      print("Enter 6 to change pin 6 state")
      doNext = input("Enter q to quit: ")
      if (doNext == "q"):
            break
      elif (doNext == "p"):
            PWMPercent = input("PWM, 0 to 100: ")
            try:
                val = int(PWMPercent)
                PWMpercent = val
                if (PWMpercent > 100):
                    PWMpercent = 100
                if (PWMpercent < 0):
                    PWMpercent = 0
            except ValueError:
                print ("*** qNot an integer")
      elif (doNext == "6"):
            if (data6 == [0x00]):
                data6 = [0x01]
            else:
                data6 = [0x00]
      print('\n\n')
      
if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        gpio.cleanup()
        sys.exit(0)

 

The Python smbus module is easier to use than the Arduino libraries.  After instantiation of an object called bus on line 9 both reads and writes of any length (up to the maximum allowed 32 bytes) can be made.

 

The following snippet illustrates how to read:

      #2 Read A1
      offset = 2
      numBytes = 2    
      block = bus.read_i2c_block_data(address, offset, numBytes)
      ADCvalue = (block[0]<<8) + block[1]
      Voltage = ADCvalue / 1023 * 3.3
  • The offset corresponds directly to the offset given in the C++ enumeration table given in the code for the Arduino
  • The variable numBytes is the number of bytes to read
  • The Python list named block contains the bytes to be read in using the function bus.read_i2c_block_data()

If there is only one byte being read in then just set numBytes to 1 and use the same code.  If necessary the bytes can be combined back into integers or other forms as shown in the example.

 

The following snippet illustrates how to write:

      #10 Write PWM
      PWM = int(PWMpercent / 100 * 255)
      msb = (PWM & 0xFF00)  >> 8
      lsb = PWM & 0xFF
      offset = 10
      data = [msb, lsb]
      bus.write_i2c_block_data(address, offset, data)
  • The offset corresponds directly to the offset given in the C++ enumeration table given in the code for the Arduino
  • If necessary the information is broken into bytes, MSB first in these examples and placed into a Python list.
  • The data is written to the slave using the function bus.write_i2c_block_data().

If there is only one byte being written then the Python list need only contain one value.

 

More Thoughts on I2C

 

I2C has advantages - it can communicate between many devices, is simple to use, and requires only two pins in addition to ground.  It is not a particularly high speed protocol however and in this demonstration the communication is at 100 kHz.  There is overhead in that for every 8 bits of data sent one extra bit of meta data (an "ACK" or "NACK" bit) must also be sent. 

 

I2C is "open drain" meaning that the bus can be pulled low but not driven high. The SDA and SCL lines require a pull-up resistor to pull the signal high.  In the example here the Arduino library and the Raspberry Pi make use of  internal pull-up resistors.  The Adafruit Feather is a 3V3 device as is the Raspberry Pi but there is a potential issue if the Arduino device is 5V.  In that case it is probably best to use a level shifter or at least not pull the voltage up above 3V3 for the sake of the Raspberry Pi.  The I2C bus is fairly robust but in the case of long connections higher pullup resistor values may cause issues and may fail entirely.  In this example 20 cm (8") jumpers between the two boards were used.  Out of curiosity, and since the oscilloscope was already hooked up, a screen shot was taken of the SDA and SCL lines.

Oscilloscope Capture of SDA and SCL Signals

The internal pullup resistors pull the signals back up to 3V3 sufficiently quickly and no glitches in transmission were observed.

 

Conclusion

 

The approach described gives the Raspberry Pi added capability and the possibility of realtime control while using only two pins.  Additionial enhancements that could be made include:

  • Adding sensors to the microcontroller such as distance sensors, line followers, etc. and having the control from the Raspbery Pi through the microcontroller to the sensor
  • Additional microcontrollers can be added
  • Interrupt(s) could be added through the use of a pin(s) on the microcontroller to the Raspberry Pi.  Since I2C allows multiple masters the microcontroller could also be used as a master but setting a pin would seem simpler and faster if available.
  • Use C / C++ directly without the Arduino library.

 

Other means of communication such as SPI or asynchronous serial could also be used.  Another alternative would be to use a Beaglebone AI for the image recognition and which has PRUs built in reducing or eliminating the need for a microcontroller.

 

Thanks for reading.  Comments, questions, and corrections are always welcome.

 

Edits / Corrections / Additions

 

2019 Dec 19:  Fixed conversion errors in the Python script, fixed link to Nick Gammon's Forum

 

Useful Links

Nick Gammon's Forum Entry on I2C

Wikipedia I2C Entry

Raspberry Pi and Arduino I2C Communication (Post #1)