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.

A State Machine – Unit Testing

Last time I talked about how I implemented a state-machine to control the lifetester that I’ve been developing. In the process, I relied heavily on unit testing the code as I wrote it. In fact, by unit testing the code while refactoring, all the development was done on my desktop machine! This is a big departure from how I used to do things a year ago where all the development that I did was using the Arduino IDE and code was compiled and run on the target. I only needed to compile for the Arduino and plug in a reference solar cell at the very end to check that everything worked as I expected and I’m pleased to say that it did. My eyes were opened to this in my first job as an embedded software engineer at CMR. I found this was one of the most striking differences between the professional and home project software development worlds. In essence, unit testing is software designed to exercise all of the behaviour of the code (as independent units) we’re intending to write. We’re trying to check that it works as we expect, whether we give the functions good or bad inputs – there are positive and negative tests. It was put to me like this once: “Unit-test the code like a burglar rather than a postman”.

How to write unit tests

I think it’s worth saying here that there are good and bad ways of unit testing and as with anything. I really like this guide. In short, unit-tests should be F.I.R.S.T:

  • Fast – probably more important on very large projects than here but who wants to wait ages for their tests to run?
  • Independent/Isolated – tests should follow the arrange, act assert format.
  • Repeatable – the order that test run shouldn’t matter and the results should be the same every time they’re run. They should be responsible for setup and teardown all of their data.
  • Self-validating – we don’t need to inspect anything to see if a test has passed or failed. The results should be reported automatically.
  • Thorough and timely – cover every use scenario and be done drive the development of the source code not be written later.

My attempt

Clearly, I can’t go through all the unit tests for this module as this post would be far too long but I can show you a couple of examples to give you an idea how this might work. Here goes…

TEST(IVTestGroup, SaturatedCurrentDetectedIncrementsErrorReadingsCounter)
{
    mockLifeTester->data.delayDone = true;
    mockLifeTester->data.iSampleSum = MAX_CURRENT;
    mockLifeTester->data.nSamples = 1U;
    mockLifeTester->data.nErrorReads = 0U;
    mockLifeTester->state = &StateMeasureThisDataPoint;
    const uint32_t tInit = 34524U;
    mockTime = tInit + SETTLE_TIME + SAMPLING_TIME;
    ActivateThisMeasurement(mockLifeTester);
    MocksForTrackingModeStep();
    MocksForMeasureDataNoAdcRead();
    StateMachine_UpdateStep(mockLifeTester);
    POINTERS_EQUAL(&StateTrackingMode, mockLifeTester->state);
    CHECK_EQUAL(1U, mockLifeTester->data.nErrorReads);
    CHECK_EQUAL(currentLimit, mockLifeTester->error);
    mock().checkExpectations();
}

Above is a test the checks that if the adc readings are saturated (the current from the device goes outside the available range), the reading is counted as a bad reading and added to a counter – the lifetester should accepts a few bad readings before transitioning to the error state. In the test module, I’ve setup a mockLifeTester variable (instance) that I reset before every test. So the first thing to do in the test is set the lifetester to the correct mode (MeasureThisDataPoint), reset the error readings counter and most importantly saturate the current reading. mockTime is my way of returning a value from millis() in the source code. You can see that I’ve incremented the timer so that the sampling window and tracking delay have expired before calling the state-machine update function. Now I do the asserts and check that we’ve transitioned back to TrackingMode (parent state), as the sampling time is over and that the error has been counted and recorded in the mockLifeTester data. Of course, too many bad readings should lead to a transition to the StateError as follows…

TEST(IVTestGroup, TrackingModeTooManyBadReadingsTransitionToErrorState)
{
    // Setup for tracking mode.
    mockLifeTester->data.nErrorReads = MAX_ERROR_READS + 1U;
    mockLifeTester->state = &StateTrackingMode;
    MocksForTrackingModeStep();
    MocksForErrorEntry(mockLifeTester);
    StateMachine_UpdateStep(mockLifeTester);
    POINTERS_EQUAL(&StateError, mockLifeTester->state);
    mock().checkExpectations();
}

All I need to do here is setup the the number of error readings above the allowed limit before calling the update function. This should lead to a transition to the error mode which will have happened for the test to pass in the POINTERS_EQUAL(...) statement.

Mocking

The question on my mind before I began unit-testing embedded code was “How do we execute code written for an embedded platform on a PC? Won’t it try to call hardware specific functions that don’t exist?”. This is accomplished by mocking – any calls to low level i/o are replaced with our own mock functions. I’ve given an example here that I needed in the last test…

static void MocksForErrorLedSetup(void)
{
    mock().expectOneCall("Flasher::t")
        .withParameter("onNew", ERROR_LED_ON_TIME)
        .withParameter("offNew", ERROR_LED_OFF_TIME);
    mock().expectOneCall("Flasher::keepFlashing");
}

static void MocksForSetDacToVoltage(LifeTester_t const *const lifeTester,
                                    uint8_t v)
{
    mock().expectOneCall("DacSetOutput")
        .withParameter("output", v)
        .withParameter("channel", lifeTester->io.dac);
}

static void MocksForErrorEntry(LifeTester_t const *const lifeTester)
{
    MocksForErrorLedSetup();
    MocksForSetDacToVoltage(lifeTester, 0U);
}

When the lifetester transitions into the error state, we expect it to set the dac and setup the flash rate of an led to indicate that the device is in its error state. So we expect our mock functions to be called. You’ll see in the tests that there’s this statement mock().checkExpectations() which is responsible for checking that the correct mocks are actually called number of times we expect. If we don’t say expectOneCall("DacSetOutput"), and a call is made to this function by the source then the test will fail. Alternatively, if we do make an expect and the function isn’t called, the test will fail too; mocking and expects are a really important tool for checking the behaviour of our code too. Asserting on the data returned is only half the picture.

 Thoughts

In this post, I’ve discussed in brief how unit testing can be used to refactor and write code that’s more robust. I hope you like it. Personally, I write unit tests for all the code that I write now even though it means you have to write twice as much code. I like the way that it helps me to think through what I’m doing and encourages me to write cleaner code where the lower layers are abstracted so they can be mocked effectively. I believe that it could be better understood and used by the ‘hacker’ community to good effect…but then would it really be hacking?

How long do solar cells live? (maximum power point tracking)

In other posts, I’ve talked about developing the lifetester board and output from the prototypes that I’ve built. So far however, I haven’t given any detail on how maximum point tracking actually works and in this post, I want to unravel things a bit. For this first attempt, I’ve gone for a really simple hill-climbing algorithm which looks like this:

In summary, It does the following steps to update the drive voltage to maintain the MPP:

  1. Scan the drive voltage and look for the maximum power point to be used as an initial guess (not shown).
  2. Set the drive voltage (V) for this point, measure the current.
  3. Set the drive voltage (V + dV) for the next point, measure the current.
  4. If Power(next) > Power(this), set V -= dV else set V += dV.
  5. Repeat step 2.

In software, the update (step) function looks like this:

void IV_MpptUpdate(LifeTester_t *const lifeTester)
{
    uint32_t tElapsed = millis() - lifeTester->timer;
  
    if ((lifeTester->error != currentThreshold)
        && (lifeTester->nErrorReads < MAX_ERROR_READS))
    {
        if ((tElapsed >= TRACK_DELAY_TIME)
            && tElapsed < (TRACK_DELAY_TIME + SETTLE_TIME))
        {
            //STAGE 1: SET INITIAL STATE OF DAC V0
            DacSetOutput(lifeTester->IVData.v, lifeTester->channel.dac);
        }
        else if ((tElapsed >= (TRACK_DELAY_TIME + SETTLE_TIME))
                 && (tElapsed < (TRACK_DELAY_TIME + SETTLE_TIME + SAMPLING_TIME)))
        {
            //STAGE 2: KEEP READING THE CURRENT AND SUMMING IT AFTER THE SETTLE TIME
            lifeTester->IVData.iCurrent += AdcReadData(lifeTester->channel.adc);
            lifeTester->nReadsCurrent++;
        }    
        else if ((tElapsed >= (TRACK_DELAY_TIME + SETTLE_TIME + SAMPLING_TIME))
                 && (tElapsed < (TRACK_DELAY_TIME + 2 * SETTLE_TIME + SAMPLING_TIME)))
        {
            //STAGE 3: STOP SAMPLING. SET DAC TO V1
            DacSetOutput((lifeTester->IVData.v + DV_MPPT), lifeTester->channel.dac);
        }
        else if ((tElapsed >= (TRACK_DELAY_TIME + 2 * SETTLE_TIME + SAMPLING_TIME))
                 && (tElapsed < (TRACK_DELAY_TIME + 2 * SETTLE_TIME + 2 * SAMPLING_TIME)))
        {
            //STAGE 4: KEEP READING THE CURRENT AND SUMMING IT AFTER ANOTHER SETTLE TIME
            lifeTester->IVData.iNext += AdcReadData(lifeTester->channel.adc);
            lifeTester->nReadsNext++;
        }
        //STAGE 5: MEASUREMENTS DONE. DO CALCULATIONS
        else if (tElapsed >= (TRACK_DELAY_TIME + 2 * SETTLE_TIME + 2 * SAMPLING_TIME))
        {
            // Readings are summed together and then averaged.
            lifeTester->IVData.iCurrent /= lifeTester->nReadsCurrent;
            lifeTester->IVData.pCurrent =
                lifeTester->IVData.v * lifeTester->IVData.iCurrent;
            lifeTester->nReadsCurrent = 0;

            lifeTester->IVData.iNext /= lifeTester->nReadsNext;
            lifeTester->IVData.pNext =
                (lifeTester->IVData.v + DV_MPPT) * lifeTester->IVData.iNext;
            lifeTester->nReadsNext = 0;

            // if power is lower here, we must be going downhill then move back one point for next loop
            if (lifeTester->IVData.pNext > lifeTester->IVData.pCurrent)
            {
                lifeTester->IVData.v += DV_MPPT;
                lifeTester->Led.stopAfter(2); //two flashes
            }
            else
            {
                lifeTester->IVData.v -= DV_MPPT;
                lifeTester->Led.stopAfter(1); //one flash
            }
            // finished measurement now so do error detection
            if (lifeTester->IVData.iCurrent < MIN_CURRENT)
            {
                lifeTester->error = lowCurrent;
                lifeTester->nErrorReads++;
            }
            else if (lifeTester->IVData.iCurrent >= MAX_CURRENT)
            {
                lifeTester->error = currentLimit;  //reached current limit
                lifeTester->nErrorReads++;
            }
            else //no error here so reset error counter and err_code to 0
            {
                lifeTester->error = ok;
                lifeTester->nErrorReads = 0;
            }
            PrintLifeTesterData(lifeTester);

            lifeTester->IVData.iTransmit =
                0.5 * (lifeTester->IVData.iCurrent + lifeTester->IVData.iNext);
            lifeTester->timer = millis(); //reset timer
            lifeTester->IVData.iCurrent = 0;
            lifeTester->IVData.iNext = 0;
        }    
    }
    else //error condition - trigger LED
    {
        lifeTester->Led.t(500,500);
        lifeTester->Led.keepFlashing();
    }
}

This function operates on a custom lifetester type that contains all the relevant information regarding the state of the device under test. We pass a pointer to this data which the update function works on. It’s a psudo-object oriented approach. C is obviously not an object oriented language but by using a struct like an instance, this function is a bit like a method. This way, we can have another lifetester instance to represent another device under test or many more if we choose and they should not interact.

As illustrated in this solution, it’s important not to block the microcontroller with calls to delay(). If you call this, the device won’t be able to update another channel say or the state of leds. I wrote this code almost a year ago and although it works, it’s not clean:

  • The function is too long – it’s doing more than one thing.
  • There are unnecessary comments. If the code were written well, it would be self-documenting.
  • There is duplication: this point and next point share almost identical code.
  • Spot the magic numbers.

I’ve now refactored this code by means of a state-machine and will present it in a coming article.

How long do solar cells live? (Part 2)

Circuit design

In this post, I want to talk about the circuit that I developed to drive solar cells at their maximum power point – the main building block of a modular lifetime tester. At this point, I should credit Sarah Sofia at MIT for her article “Build Your Own Sourcemeter“. This is what really gave me the inspiration and got me thinking that this would actually be possible with an Arduino and simple electronics.

Circuit layout of the prototype lifetime tester composed of DAC, op-amp and ADC interfaced by SPI with an Arduino UNO. The Arduino is interfaced with a PC through the serial port. Note that only one channel is shown here (one DUT).

A schematic of the lifetime tester circuit is shown above. In essence, the system is composed of:

  • a two-channel DAC (MCP4822) to give me the drive voltage across the solar cell. Because there are two channels I can run two solar cells at the same time. Typically, several subcells (6-8) are made on the same substrate so here, we can test two subcells of the same device at the same.
  • solar cell output is dumped into separate small (10-100 Ohm) series resistors which allow us to measure the current from the voltage dropped across them (applying Ohm’s law). Since resistance values and currents are small, the voltage drop will be small (we don’t want to drop much voltage in our ammeter).
  • an opamp is then needed to (on each channel) to bring the voltage to something that an ADC (ADS1286) can actually read. In fact, I’ve used an inverting op-amp with variable gain up to 1000x. To account for the fact that different solar cells under test might have different efficiencies and could therefore supply a different current, the gain is variable.

In this circuit, only the fourth quadrant (power generating region of the IV characteristic) can be accessed. Under operation, a solar cell will supply a current in the opposite direction to the applied bias. This means that the voltage across the series resistor will, in fact, be negative – one terminal is grounded, the other will be at some voltage below 0V. This signal gets fed into an inverting opamp which flips it positive again and amplifies it too. Any positive voltage at the input here will be rejected as it will be inverted to a negative output and will hit the 0V supply rail of the opamp. This means that if you try to run the solar cell in forward bias above the open-circuit voltage, giving you a forward current, you won’t get any output from the amplifier. I’ve tried to give you an illustration of this in the figure below (see lower panel).

(upper panel) LT spice model used to simulate and design the lifetime tester circuit. Note that the solar cell (DUT) has been modelled as a diode, current source and some resistors. (Lower panel) simulated input voltage from the DUT (blue line) and output (red line) voltage from the op-amp.

So did it work?…

To answer this, I connected up a solar cell and just went for a simple voltage sweep from the DAC while monitoring the current using the ADC. Here’s what the data looked like…

Using the prototype lifetime tester circuit to measure an I-V characteristic from a perovskite solar cell under illumination (black line). Power output (red line) has been calculated from DAC binary value * ADC binary value.

I was pretty happy when I saw this data. As you can see, it looks almost exactly how we expected it to from simulations and from understanding how an inverting op amp should operate. Furthermore, the fact that we can get power output curve and see a clear maximum power point (MPP) means things are looking good for doing MPP tracking. There’s some noise but I think this might have been from the fact that I was using the torch on my phone as an illumination source and it was hard to hold it exactly still. Since the measurement takes several seconds to complete, shaky hands could well be the culprit.

If you’re interested in how I coded this, then please follow the link.

…now for the digital-to-analogue converter

Now I’m dealing with the mirror case to the ADC that I just posted on. Instead of us reading an analogue voltage and converting that to a digital representation, we want an analogue voltage from a digital signal (DAC). In other words, I want the Arduino to give me a voltage from an int. But this is just like the AnalogWrite() function right?… Wrong!

  1. AnalogWrite()  does not give us an analogue voltage. It gives us a pulse width modulated (PWM) digital signal ie. the output is either 0 or 5V and we change the ratio of on and off time to give us an analogue-like signal for driving motors for example. This is not truly analogue in that what we want is to be able to select any DC  voltage between 0 and 5V that we want. We could apply a filter to the PWM signal to convert it to DC and this would be a cheap and fast trick but we would lose speed and voltage.  Speed is a key figure of merit in a digital-to-analogue converter (DAC) systems as it determines the sampling rate.
  2. Resolution – if we were to go with option 1 plus filter, with the Arduino, we’re limited to a resolution of 8 bits ie. the output voltage would be scaled between 0-255*Vout. On the other hand with an external DAC, we could choose our resolution up to 32 bits ie. 0-4294967296!
The circuit schematic that I used to test out the MCP4822 DAC. Note that I used a digital voltmeter connected to pin 8 (Va output) to monitor the output and I sent commands over SPI using an Arduino UNO.

So being able to use a DAC is going to be an important tool in our kit for attacking lots of projects. These things are commonplace in A/V equipment and mobile phones but I have another project in mind for this. More on that later. First, let’s discuss how to use a DAC – in this case the 12bit MCP4822. Se pinout here…

Pinout diagram for the MCP4822 DAC

If you’ve not done so already, have a look back at my previous post on using an ADC which covers the basics of SPI connections, bit math and relevant code.

Same as before, we’re going to setup an SPI connection between the DAC and Arduino but this time we transfer data over the master-out-slave-in (MOSI) line. The process will look like this:

  1. Setup the SPI interface first given the settings in the chip datasheet.
  2. Take user input from the serial connection and store it as text.
  3. Separate the channel address, voltage and gain from the users command and store as char, int and int respectively.
  4. Convert our int representation of voltage to two binary bytes.
  5. Change the state of some of the leading bits to denote which channel of the DAC we’re addressing (A or B) and what gain we would like (1 or 2).
  6. Take the CS pin LOW and transfer two bytes via SPI (MSB first). Then return the CS pin to HIGH again.
  7. Print the result to the serial window.
  8. Repeat
SPI communication protocol for the MCP4822 DAC.

Above you can see an extract from the datasheet that illustrates the SPI protocol. So how do we actually set the voltage that we want? Well, the MCP4822 has two channels, two gain settings and a 12-bit DAC register meaning and the voltage is calculated as follows…

Vout = Gain*Vref*(D/4095)

…where the gain is either 1 or 2, Vref=2.04V and is the internal voltage reference of the DAC, and D is the binary input that we send to the unit over SPI. Having an internal reference voltage means that even though the supply voltage may change slightly, the output is going to be stable since Vref is a stabilised internal reference…Nice! So this means we can choose whether we want to scale our voltage over 0.00-2.04V or 0.00-4.08V by the gain setting, either large/small range low/high resolution depending on our application, by using the gain setting.

Here is an extract from the MCP4822 datasheet for further information. It shows how the output is calculated and the writing process with example input. You can see that the before our binary input (left of the most significant bit) there are some extra bits referred to as config bits which in my code are inside the most significant byte (named MSB in the following code). We need to concentrate on bit 15, the channel selector, bit 13, the gain selector and bit 12, the shutdown bit…

 

Extract from the MCP4822 datasheet showing the write command register. I’ve also given you an example of the kind of thing you would send to the unit and what that actually means.

Now let’s discuss how the code could look. The basic process is given in the numbered list above but let’s elaborate here (You’ll find a complete copy of the code at the bottom of the post):

Setup the SPI interface

  // set the CS as an output:
  pinMode (DAC_CS, OUTPUT);
  Serial.begin(9600);     // opens serial port, sets data rate to 9600 bps
  inputString.reserve(200); // reserve 200 bytes for the inputString:
  SPI.begin();

Take user input from the serial connection

  // read the incoming string:
  inputString = Serial.readString();
  // say what you got:
  Serial.print("I received: ");
  Serial.println(inputString);

Separate the channel address, voltage and gain from the user’s command and store as char, int and char respectively

//extract channel address
  channel = inputString.charAt(0);
  //and gain
  gain = inputString.charAt(1);
  //convert string to an int - binary voltage
  n = inputString.substring(2).toInt();
  //set DAC state
  DAC_set(n, channel, gain, DAC_CS, errmsg);
  //print errors if there are any
  Serial.println(errmsg);

  //clear input string ready for the next command
  inputString = "";

Convert our int representation of voltage to two binary bytes

  //convert decimal input to binary stored in two bytes
  MSB = (input >> 8) & 0xFF;  //most sig byte
  LSB = input & 0xFF;         //least sig byte

Set the config bits

You will see that setting config bits is done with bit manipulation. In essence, if you want to set bit 7 to a 1, you need to do an OR operation on your data with 10000000 (which is 0x80 in hex) – this is useful in setting the channel bit. If instead, you want to set the same bit to 0, you would do an AND operation with 01111111 (0x7F) so you would end up setting bit 7 to 0 and keeping any data that you already had. If you did, AND 00000000, all your bits would go to 0 of course.

 //apply config bits to the front of MSB
  if (DAC_sel=='a' || DAC_sel=='A')
    MSB &= 0x7F; //writing a 0 to bit 7.
  else if (DAC_sel=='b' || DAC_sel=='B')
    MSB |= 0x80; //writing a 1 to bit 7.
  else
    errmsg += "DAC selection out of range. input A or B.";

  if (Gain_sel=='l' || Gain_sel=='L')
    MSB |= 0x20;
  else if (Gain_sel=='h' || Gain_sel=='H')
    MSB &= 0x1F;
  else
    errmsg += "Gain selection out of range. input H or L.";

  //get out of shutdown mode to active state
  MSB |= 0x10;

Take the CS pin LOW and transfer two bytes via SPI (MSB first). Then return the CS pin to HIGH again.

  //now write to DAC
  // take the CS pin low to select the chip:
  digitalWrite(CS_pin,LOW);
  delay(10);
  //  send in the address and value via SPI:
  SPI.transfer(MSB);
  SPI.transfer(LSB);
  delay(10);
  // take the CS pin high to de-select the chip:
  digitalWrite(CS_pin,HIGH);

Print the result to the serial window.

  Serial.println("binary input to DAC: ");
  Serial.print(MSB,BIN);
  Serial.print(" ");
  Serial.println(LSB,BIN);

Repeat

In the serial monitor, you should see something like this…

I received: AL500

binary input to DAC: 
110001 11110100

I received: AL0

binary input to DAC: 
110000 0

I received: al1000

binary input to DAC: 
110011 11101000

I received: al0

binary input to DAC: 
110000 0

I received: AL1000

binary input to DAC: 
110011 11101000

You get the command back that you wrote in then the binary input to the DAC and any errors.  To check that the code and hardware were working, I measured the output from the DAC as a function of binary input. Here is what I got…

What follows is the final code that I used to check my DAC. It contains separate functions for setting the state of the DAC and reading user input from the Arduino Serial monitor. I hope this post is enough to give you the basics of DAC implementation using an Arduino. If you have any questions, please comment and I’ll do my best to answer. Note that this code is limited in terms of speed. I’ve included some short (10ms) delays in the code which would really slow the application of the DAC if we wanted to sample at high data rates. One to return to at a later date.

/*
D. Mohamad 29/03/17 code to commumincate with MCP4822 12-bit DAC via SPI
pin assignments as follows...
      Uno (master)  MCP4822 (slave)
CS    8             2
MOSI  11            4
SCK   13            3
Read output voltage with a multimeter Va/Vb on pin 8/6.
send commands via serial interface eg.
AL1000 would mean...
channel=A
gain=low
D=1000
Va = gain*Vref*D/4095
= 1*2.04*1000/4095 = 0.498V
see www.theonlineshed.com
 */


#include <SPI.h>

const int DAC_CS = 8; //Chip select pin for the DAC
String inputString = ""; //holds serial commands

void setup() {
  // set the CS as an output:
  pinMode (DAC_CS, OUTPUT);
  Serial.begin(9600);     // opens serial port, sets data rate to 9600 bps
  inputString.reserve(200); // reserve 200 bytes for the inputString:
  SPI.begin();
}

//function to set state of DAC - input value between 0-4095
void DAC_set(unsigned int input, char DAC_sel, char Gain_sel, int CS_pin, String &errmsg)
{
  //DAC_sel choose which DAC channel you want to write to A or B
  //Gain_sel choose your gain: H=2xVref and L=1xVref
  byte MSB,LSB;//most sig, least sig bytes and config info

  //clear error messages
  errmsg="";

  Serial.flush();  // in case of garbage serial data, flush the buffer
  delay(10);

  //only run the rest of the code if binary is in range.
  if (input<0 || input >4095)
    errmsg += "input out of range. 0-4095.";
  else
  {
  //convert decimal input to binary stored in two bytes
  MSB = (input >> 8) & 0xFF;  //most sig byte
  LSB = input & 0xFF;         //least sig byte
  
  //apply config bits to the front of MSB
  if (DAC_sel=='a' || DAC_sel=='A')
    MSB &= 0x7F; //writing a 0 to bit 7.
  else if (DAC_sel=='b' || DAC_sel=='B')
    MSB |= 0x80; //writing a 1 to bit 7.
  else
    errmsg += "DAC selection out of range. input A or B.";

  if (Gain_sel=='l' || Gain_sel=='L')
    MSB |= 0x20;
  else if (Gain_sel=='h' || Gain_sel=='H')
    MSB &= 0x1F;
  else
    errmsg += "Gain selection out of range. input H or L.";

  //get out of shutdown mode to active state
  MSB |= 0x10;

  Serial.println("binary input to DAC: ");
  Serial.print(MSB,BIN);
  Serial.print(" ");
  Serial.println(LSB,BIN);

  //now write to DAC
  // take the CS pin low to select the chip:
  digitalWrite(CS_pin,LOW);
  delay(10);
  //  send in the address and value via SPI:
  SPI.transfer(MSB);
  SPI.transfer(LSB);
  delay(10);
  // take the CS pin high to de-select the chip:
  digitalWrite(CS_pin,HIGH);
  }
}

//function to read user command from the serial command window and set the DAC output
void serial_DAC_set(void)
{
  unsigned int n; //number 2 bytes long unsigned
  char channel, gain;
  String errmsg;  //errors returned from DAC_set
  
  // read the incoming string:
  inputString = Serial.readString();
  // say what you got:
  Serial.print("I received: ");
  Serial.println(inputString);
  
  //extract channel address
  channel = inputString.charAt(0);
  //and gain
  gain = inputString.charAt(1);
  //convert string to an int - binary voltage
  n = inputString.substring(2).toInt();
  //set DAC state
  DAC_set(n, channel, gain, DAC_CS, errmsg);
  //print errors if there are any
  Serial.println(errmsg);

  //clear input string ready for the next command
  inputString = "";
}

void loop()
{
  //only run when data is available
  if (Serial.available() > 0)
    serial_DAC_set();
}

My guide on using an analogue-to-digital converter

In this example, I’ve wired up an Analogue-to-digital converter (ADC) and showed you some code that will get this device talking to an Arduino using the SPI connection. There’s a potentiometer here that you can use to change the input voltage and it’s read once per second. Note that ADCs are capable of reading at much higher rates than this – according to the data sheet, the ADS1286 can sample at up to 20kHz but higher rates are possible.

ADC (ADS1286) circuit diagram. Device connected to Arduino Uno board with communication provided via SPI.

SPI stands for Serial Peripheral Interface and is a really impressive invention that allows ICs to talk to one another over short distances (on a PCB) using three wires: (1) clock, (2) master-out-slave-in (MOSI)/master-in-slave-out (MISO) and (3) chip select. In our case, since we’re dealing with receiving data from the slave (ADC), then we only use the MISO line. Wikipedia and Arduino have really good entries explaining this. However, I think that the timing diagram on Wikipedia is really helpful. Basically, when we want to talk to a chip over SPI, we send the chip select pin low, then send/read data over the MOSI/MISO data line. The whole thing is synchronised by clock pulses on the clock line. Each time the clock switches state, we end up reading another bit. The exact protocol of whether we (a) read bits on a low-high or high-low clock pulse and (b) whether bits are read on the leading or trailing edge of the clock is set in the SPI settings in the code (see below). Here is the exact operating sequence from the datasheet:

ADS1286 operating sequence straight from the datasheet. It’s not clear from this diagram but the clock polarity is 0 ie. it’s normally low and data bits are read on the trailing edge of the clock pulse. We read two bytes of data here and the most significant byte (MSB) comes out first.

In the code below, you can see that the process goes like this:

  1. Setup the SPI interface first given the settings discussed above and also set the clock speed as recommended by the manufacturers (It doesn’t seem to work if I don’t set this manually).
  2. Take the CS pin LOW and read out two bytes via SPI (MSB first). Then return the CS pin to HIGH again.
  3. Do some bit maths on these two bytes to convert them into a usable binary number.
  4. Cast this binary number into an int.
  5. Print the result to the serial window.
  6. Repeat.

Here’s some example raw data that we might read from the unit. The whole thing looks like this…

Format of data read from the ADC. Note that we read 16 bits but only 12 of them are actually useful. What follows in the code is an effort to shift things around and get rid of the junk bits in steps (1) to (4).

So what we need to do is keep only the data inside the box and get rid of everthing else. The key line in the code is this one…

ADCval = ((MSB & 0x3e)<<8 | LSB) >> 1;

Notice that we do a bitwise AND first with the MSB and 0x3e which is hex for 00011111 (step 1). This effectively does an AND operation with each bit in the MSB  and this number. Therefore anything in the first three bits gets switched to a 0 because any bit AND 0 -> 0. Then we shift the MSB left 8 places (step 2) and do a bitwise OR with the LSB (step 3). This effectively moves the MSB into its correct position as the most significant data and stuffs the LSB onto the end. Lastly, we shift everything right one place to get rid of the junk hanging off the end (step 4). This effectively shifts it off the end. Note that this will only work because we have declared ADCval as an unsigned int. If not, instead of a 0 being moved in from the left as we shift everything right, a 1 would end up there instead.

And here’s the rest of the code which includes the steps for addressing the ADC.

#include <SPI.h>
//pin connections
//Arduino       ADC
//12 MISO       6 (with 10k pull-up)
//10 CS         5
//13 SCK        7
const int CS = 8;

void setup() {
  // set the CS as an output:
  pinMode (CS, OUTPUT);
  Serial.begin(9600);     // opens serial port, sets data rate to 9600 bps
  
  SPI.begin();
  SPI.beginTransaction(SPISettings(20000, MSBFIRST, SPI_MODE0));
}

//function to read state of ADC
int ADCread(void)
{
  unsigned int ADCval=0;
  byte MSB,LSB;

  //now write to DAC
  // take the CS pin low to select the chip:
  digitalWrite(CS,LOW);
  delay(10);
  //  send in the address and value via SPI:
  
  MSB = SPI.transfer(0x00); //most sig byte
  LSB = SPI.transfer(0x00); //least sig byte

  //print out the raw measurement
  for (int i=7;i>=0;i--)
    Serial.print(bitRead(MSB,i));   
  for (int i=7;i>=0;i--)
    Serial.print(bitRead(LSB,i));
  Serial.println();

  //now get rid of the first three digits (most sig bits)
  //combine with the LSB
  //shift everything right one to get rid of junk least sig bit
  ADCval = ((MSB & 0x3e)<<8 | LSB) >> 1;

  //print out data again
  for (int i=15;i>=0;i--)
  Serial.print(bitRead(ADCval,i));
  Serial.println();
  
  delay(10);
  // take the CS pin high to de-select the chip:
  digitalWrite(CS,HIGH);

  return(ADCval);
}

void loop()
{
  int data = ADCread();
  Serial.println(data);
  delay(1000);
}

Here’s an example of the kind of thing that you get on the serial monitor…

1100110110101101
0000011001010110
1622
1100110110101010
0000011001010101
1621
1100110110101101
0000011001010110
1622
1100110110101101
0000011001010110
1622
1100110110101111
0000011001010111
1623
1100110110100111
0000011001010011
1619
1100011111001010
0000001101100101
869
1100010011110000
0000001001111000
632
1100000100101000
0000000000010100
20
1100000000000000
0000000000000000
0
1100000000000000
0000000000000000
0

…first, you get the raw data read from the ADC followed by the shifted bits after we do our maths. Then you get the decimal value representing the voltage which is Vin/Vref*4095.

I decided it might be a good idea to check the linearity of the device. I wanted to be able to relate the binary output from the ADC to a real voltage. Testing this is pretty easy.  I monitored the wiper terminal (ADC input) using a digital voltmeter and compared it to the ADC output from the serial window. Here is what I got…

As you can see, everything looks rosy. The response is linear to a high degree of accuracy and the gradient is 812 V^-1 = 4095 binary / 5.042V. I hope this post will help explain the basic process of how to implement an ADC. Get in touch if I’ve missed anything.