So I'm dusting off a project that I'm going to ramp up here in the coming summer months. It's a thermostat for home assistant. Home assistant has a built in generic thermostat, however I want to add in fan speeds to increase airflow if rooms just aren't cooling off. Now in that project, I did something interesting that deserves a look.

Now because I've spent my decade career in embedded programming (mostly C and trying to get back to VHDL, my programming unicorn), I am writing control modules in C, and hopefully import those modules into Python (my go-to do something random in less than 10 lines) and then into home assistant. Unit Testing C for embedded devices is a bit of a hellscape. Companies like Microchip have dropped the ball on complementing their tools with unit test frameworks, device manufacturers are slow to adopt continuous delivery, and electrical engineers turned firmware engineers (comme moi) never understood software unit testing until they painfully had to learn it.

Shiny new thing

As I mentioned, I choose python for my high level language because there are so many well documented packages in it's ecosystem that I can get moderately complex programs in few lines of code. Because python handeling of classes is so loose, it makes importing a bunch of package components and splicing the together super essy. Need a we control for some power supply? Import niceui and pyserial packages and write a couple functions and bam, control from any device.

The CFFI up package is a way to compile or import C code and libraries into your python project and interact with them. CFFI is hailed as faster than the ctypes builtin library although this may be only because you can compile it at program start. The package allows you to load the library and interact with your C objects in the library.

Pytests is the other package. I like using it not because the syntax of pytests is easy to pick up, and the reporting is top notch in features and flexiblity. With the wide availability of python, its an easy testing system to get installed. (I leave out unit testing specifically because I use pytests as an easy testing backend.)

Writing a library

For the thermostat, I started with a concept of Deadbands. Deadband functions by activating an output when a low value is reached, and deactivating a value when a high value is reached. Most thermostat use a 1-2F difference in temperature, although they display a steady value for us silly humans. For this purpose, I'm just going to focus on the following code:

deadband.c

/* A object to enforce deadbands
    @author Tim VanHove
    @date 11/25/2024
*/

#include 
#include "deadband.h"

int DBND_initDeadband(DBNDt_Deadband *deadband, uint16_t max, uint16_t min, uint16_t hysteresis) {


    const bool isValidHysteresis = !(hysteresis > DBND_HYST_MAX_VAL);
    const bool isValidMaxMinusMin = ((int)max - (int)min) >= (int)hysteresis;

    if (!(isValidHysteresis && isValidMaxMinusMin))
        return DBND_ERROR_INIT_BAD;

    deadband->dbnd_valueUpper = max;
    deadband->dbnd_valueLower = min;
    deadband->dbnd_hysteresis = hysteresis;
    return 0;
}


int DBND_setHighLow(DBNDt_Deadband *deadband, int high, int low) {

    const bool isNotValidHigh = high < DBND_HYST_MAX_VAL;
    const bool isNotValidLow = low < DBND_HYST_MAX_VAL;
    if (isNotValidHigh || isNotValidLow)
        return DBND_ERROR_SET_LIMIT;

    const bool isHighGreaterThanLow = low > high;
    const bool isHysteresisOK = deadband->dbnd_hysteresis <= high - low;
    if (isHighGreaterThanLow && isHysteresisOK)
        return DBND_ERROR_SET_HYST;

    deadband->dbnd_valueUpper = high;
    deadband->dbnd_valueLower = low;
    return DBND_ERROR_NONE;
}

deadband.h

/* A interface for deadband enforcement
    @author Tim VanHove
    @date 11/25/2024
*/

#include 

#define DBND_HYST_MAX_VAL 0x0FFF

#define DBND_ERROR_INIT_BAD -3
#define DBND_ERROR_SET_HYST -2
#define DBND_ERROR_SET_LIMIT -1
#define DBND_ERROR_NONE 0

typedef struct DBND_struct {
    uint16_t dbnd_hysteresis; ///< The Value that should not be enforced
    uint16_t dbnd_valueUpper; ///< The upper value
    uint16_t dbnd_valueLower; ///< The lower value
} DBNDt_Deadband;

int DBND_initDeadband(DBNDt_Deadband *deadband, uint16_t max, uint16_t min, uint16_t hysteresis);

int DBND_setHighLow(DBNDt_Deadband *deadband, int high, int low);

Just looking at the init function, we want to make sure the high values and low values, as well as the hysteresis of the object to not interfere. So in order to test that, lets compile the code into a library using the following command:

gcc --coverage --shared -fPIC -o deadband.so -I ../src ../src/deadband/deadband.c

Lets look at the flags: --coverage is to generate coverage results. --shared and -fPIC is to generate a library that can be loaded on the host architecture. The other flags indicate source.

Now this produces the deadband.so object. (There is a caveat that we can produce objects with undefined symbols, but we may explore that later. That's also not this code.) That object is now loaded into the pytests program and is ready for fuzzing.

Pytest Program

Lets look at the pytest script.

import cffi
import pytest

ffi = cffi.FFI()

includes = open("../src/deadband/deadband.h").read()
includes = includes.replace(r'#include ',"")
ffi.cdef("""
""" +
includes)

C = ffi.dlopen("./deadband.so")

@pytest.mark.parametrize("max",range(0,250))
@pytest.mark.parametrize("min",range(0,250))
@pytest.mark.parametrize("hysteresis", range(1,10))
def test_deadband_init(max,min,hysteresis):
    teststruct = ffi.new("struct DBND_struct *")
    testOK = C.DBND_initDeadband(teststruct , max, min, hysteresis)

    goldeneval1 = bool(max-hysteresis >= min)
    eval2 = testOK == 0

    assert goldeneval1 == eval2

First to point out is that before the test_ functions, the program loads, and sets up the shared library. Then it hits the initialization function. The only nonobovious thing is that I remove the problem include statements in the include file and load that into CFFI.

First thing to point out, the parameterize statements are a way to test a list of values that can be entered into the function. This test only tests what I would consider reasonable values, however, it may be a banafit to extend the test beyond reasonable values. Parameterize is a great way to test a bunch of combinations and see if abbarant behavior is presented and under what conditions.

The assert statement is just a double check of the "thought of" functionality. We are just checking that the C function got initialized correctly, or reported that it wasn't initialized correctly.

More Reports

Remember when we compiled the C library with a --coverage flag? Well now here's when it pays off. We can execute the command:

gcovr -r ../src/Deadband .

And this will give us a report:

------------------------------------------------------------------------------
                           GCC Code Coverage Report
Directory: ../src/deadband
------------------------------------------------------------------------------
File                                       Lines    Exec  Cover   Missing
------------------------------------------------------------------------------
deadband.c                                    21       0     0%   9,12-13,15-16,18-21,25,27-30,32-35,37-39
------------------------------------------------------------------------------
TOTAL                                         21       0     0%
------------------------------------------------------------------------------

For the estudious of you, yes this is totally correct in that I have only tested one function out of the 2 I have written. But I can look at this output and see that at least all of what I have tested is getting executed.

Why

Looking at C unit testing, embedded engineers look at difficult decisions in either adopting a framework and moving their code to fit it, or focusing on Hardware-in-loop test fixtures. Unit tests should be considered a work product of equal level to the actual software, but the value of unit testing is a realization of requirements and a great structure to enforce requirements as the product goes into the maintainence stage of it's lifecycle.

This method of fuzzing is important to create a strong deliverables. Security Research is ramping up more than ever with more critical devices being connected. C is especially prone to issues with memory security, but it's a difficult sell for a company to retrain it's engineers and translate it's IP into a new language rather than implement proctices to make their existing and upcoming products more secure. Pytest is also a great testing framework to export results to your favorite CI/CD framework. I am planning on extending with a GitHub Actions framework reporting.

When I looked at retrofitting projects with unit testing, I was scared off with cross compilation and non conformed frameworks. All unit testing requires some sort of cross compilation and adaptation. Implementing unit testing forces engineers to think about their code structure and create more concise and portable code. Unit test frameworks can be difficut to install and implement in a CI system. Hopefully, this method help you think about and improve your embedded code at a minimal cost and tooling.