Taking control

Up to this point, we’ve only looked at software for the lifetester internals. For the unit to actually be of any use it needs to talk to the outside world. Obviously with Arduino, there’s the serial (UART) interface which is useful for sending and receiving strings but if we were to test many solar cells in parallel, we’d need a different bus for each device. With I2C however, we can connect many (over 100) devices to the same bus, each as a slave, and interface with all of them from a single master device. I felt it was important to make this interface light and to only use single byte commands since communication could be slower this way especially along reasonably long wires. Note that I2C was developed for communication between devices on the same board not board-to-board.

Protocol

Bit76543210
FunctionChA/BR/WRDYERRERRCMDCMDCMD

Note that in addition to the byte-wide command register, there are longer registers for storing measurement parameters and data.

Communication with the lifetester is done through a single byte-wide register with each bit used for the functions shown in the table above.  Generally, the master should write its command into the register, poll on the RDY bit and then read from the requested register when the slave says “I’m ready” through the RDY bit. Here’s how the you would retrieve data from the lifetester channel A to illustrate:

  1. Master requests a write to the command register by writing 0x40.
  2. Now the master is allowed to write its command which will be 0x02 for a request to read channel A’s data.
  3. Now the master will poll the slave by requesting a read of the command register – it writes 0x00. Then it reads a byte and checks the RDY bit.
  4. Once the RDY bit is set to 1 by the slave, the data is ready and the slave can again request a read of the data register and read out 13 bytes (full length of the data register).
Procedure used by the master device when reading measurement data from a slave lifetester over I2C.

Implementation details

So here’s how I actually did this. First thing to note is that the Arduino already has functions implemented for I2C communication as the Wire library. They expect you to register some functions (callbacks) to be registered for when data is received from or when data is requested by the master. I’ve attached the functions here in the setup function called in main…

void setup(void)
{
  ...
  Wire.begin(I2C_ADDRESS);      // I2C address defined at compile time
  Wire.setClock(31000L);
  Wire.onRequest(Controller_RequestHandler);
  Wire.onReceive(Controller_ReceiveHandler);
  ...
}

…notice that I’ve set the clock speed deliberately slow as I’m concerned about the speed of transmission over long cables. Perhaps a better way to do this would have been to setup the I2C bus in differential mode with something like this – something for the next board revision perhaps.

Data sent from master to slave

When the lifetester slave receives data from the master, the following function is called. The general idea, is to check the current contents of the command register first. This tells us whether (a) a new command is being written, (b) measurement parameters are being written or (c)

STATIC DataBuffer_t transmitBuffer;
// We keep a static (module scope) copy of the command register
STATIC uint8_t      cmdReg;
static bool         cmdRegReadRequested = false;

void Controller_ReceiveHandler(int numBytes)
{
    // Tell the user that data is being transmitted with the LED
    digitalWrite(COMMS_LED_PIN, HIGH);

    /* Look at the current command in the command register. This tells us
     what to do with this new data from the master. Is the master writ-
     ing to the params register? If so, read in the new params.*/
    if ((GET_COMMAND(cmdReg) == ParamsReg)
        && IS_WRITE(cmdReg))
    {
        if (numBytes == PARAMS_REG_SIZE)
        {
            ReadNewParamsFromMaster();
            // protect from another write without command
            SET_READ_MODE(cmdReg);
        }
        else
        {
            // chuck away bad settings - wrong size
            FlushReadBuffer();
            SET_ERROR(cmdReg, BadParamsError);
        }
    }
    else // expect a new command to be written from the master...
    {
        const uint8_t newCmdReg = Wire.read();
        // Make sure old commands don't fill up buffer
        FlushReadBuffer();
        /* to write a new command, the master needs to request a write to the 
           command register and it's only accepted if the write bit is set and 
           the device is ready. */
        if (GET_COMMAND(newCmdReg) == CmdReg)
        {
            if (IS_WRITE(newCmdReg))
            {
                if (IS_RDY(cmdReg))
                {
                    LoadNewCmdToReg(newCmdReg);
                }
                else
                {
                    SET_ERROR(cmdReg, BusyError);
                }
            }
            // Master requested read command reg - see request handler
            else
            {
                cmdRegReadRequested = true;
            }
        }
        /* write to command register already requested now receiving new command
           we only allow a write to the command register if one has been requested
           as above. */
        else if (GET_COMMAND(cmdReg) == CmdReg)
        {
            LoadNewCmdToReg(newCmdReg);
            UpdateStatusBits(newCmdReg);
        }
        else
        {
            // TODO: handle this. received undefined command
        }
    }
    digitalWrite(COMMS_LED_PIN, LOW);
}

Once a command has been written to the slave, it’s time to update the status bits as follows. We need to do things like clear the ready bit if we’re about to load data into the data register, for example or set the error bits if an unknown command is issued…

static void UpdateStatusBits(uint8_t newCmdReg)
{
    // Commands are represented by a custom type (enum)
    const ControllerCommand_t c = GET_COMMAND(newCmdReg);
    if (IS_WRITE(newCmdReg))
    {
        switch (c)
        {
            case Reset:
            case ParamsReg:
            case CmdReg:
                CLEAR_RDY_STATUS(cmdReg);  // only applies for reading/loading
                break;
            case DataReg: // Master can't write to the data register
            default:
                SET_ERROR(cmdReg, UnkownCmdError);
                break;
        }
    }
    else  // read requested
    {
        switch (c)
        {
            case Reset:
                CLEAR_RDY_STATUS(cmdReg);
                break;
            case ParamsReg:
            case DataReg:
                // data requested - need to load into buffer now. Set busy
                CLEAR_RDY_STATUS(cmdReg);
                break;
            case CmdReg:  // command not loaded - preserve reg as is for reading
                break;
            default:
                SET_ERROR(cmdReg, UnkownCmdError);
                break;
        }
    }
}

Updating status

Every time a command is written, the lifetester has to actually do something with it by “consuming” it. You can see the update function here that is responsible for responding to incoming commands. It’s a simple switch statement that decides what to do based on the command bits of the command register (I’ve used an enum to store the commands and macros to read the out the various bits and fields).

void Controller_ConsumeCommand(LifeTester_t *const lifeTesterChA,
                               LifeTester_t *const lifeTesterChB)
{
    LifeTester_t *const ch = 
        (GET_CHANNEL(cmdReg) == LIFETESTER_CH_A) ? lifeTesterChA : lifeTesterChB;
    switch (GET_COMMAND(cmdReg))
    {
        case Reset:
            if (!IS_RDY(cmdReg))  // RW bit ignored
            {
                StateMachine_Reset(ch);
                SET_RDY_STATUS(cmdReg);
            }
            break;
        case ParamsReg:
            if (!IS_WRITE(cmdReg))
            {
                WriteParamsToTransmitBuffer();
                SET_RDY_STATUS(cmdReg);
            }
            else
            {
                FlushReadBuffer();
                SET_RDY_STATUS(cmdReg);
            }
            break;
        case DataReg:
            if (!IS_WRITE(cmdReg))
            {
                if (!IS_RDY(cmdReg))
                {
                    // ensure data isn't loaded again
                    WriteDataToTransmitBuffer(ch);
                    SET_RDY_STATUS(cmdReg);                    
                }
            }
            break;
        default:
            break;
    }
}

Data requested from slave by master

This is an easy one. By the time data is requested from the slave, data should either be in the transmit buffer to be sent, in which case transmit it, otherwise there’s an error condition and we should set the error bits.

void Controller_RequestHandler(void)
{
    digitalWrite(COMMS_LED_PIN, HIGH);
    if (cmdRegReadRequested)
    {
        cmdRegReadRequested = false;
        Wire.write(cmdReg);
    }
    else
    {
        if (!IsEmpty(&transmitBuffer))
        {
            TransmitData();
        }
        else
        {
            SET_ERROR(cmdReg, BusyError);
        }
    }
    digitalWrite(COMMS_LED_PIN, LOW);
}

Summary

We’ve covered how to make a two-way interface with a microcontroller through a byte-wide command register using the native Arduino I2C libraries. With it, we can issue commands, read status, and check for errors. I hope this was enough to convince you that you can do a lot with just a single byte. We haven’t covered how you read and write bits by doing bitmath. Let’s have a look at that in another post.

Leave a Reply

Your email address will not be published. Required fields are marked *