Isolating Your Hardware
Hardware projects bring with them their own unique set of problems. Let’s say, for example, you want to develop a state machine which progresses through a series of states each time you press a button and displays this state as a combination of two LEDs. You order yourself a new development board, a couple of LEDs, a button and a breadboard. The parts are scheduled to arrive sometime next week. Now what? Do you just wait? How can you approach writing this code if you don’t have the hardware to run it on yet? How do you test functions which interact with external hardware? Even once you do have the parts, how do you know if any potential problems are caused by your code or your hardware? In this post, we’ll look at solving these problems using external functions and event queues.
External Functions
External functions allow us to replace the implementation of a function easily. For those of you who are familiar with interfaces, they work in a similar way to those, with a few caveats. Usually, overriding the functions of an interface is optional, with a default implementation being available as a fallback. For cases like ours when we’re using C, we must implement a new function. This is because a different build step is carried out depending on which implementation you are . using meaning that the default is not available. We also have a different set of dependencies tied to the environments in which the different implementations will run. For our example, we’re going to create a function which sets a gpio pin when running on hardware, but simply prints a log and sets a flag during testing. Obviously, a function which is trying to set a gpio pin won’t work in an environment without a gpio pin. We start by declaring the functions we want to make replaceable as external functions.
#ifndef STATE_MACHINE
#define STATE_MACHINE
extern void firstState(void);
extern void secondState(void);
extern void thirdState(void);
extern void fourthState(void);
void triggerEvent(void);
void createDeviceState(void);
#endif
state_machine.h
We can then provide an implementation for these functions separate from the state machine itself. By not providing these implementations in state_machine.c, we ensure that they will be utilised by the state machine when our software is running on our hardware, but not during testing.
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <driver/gpio.h>
void firstState(void) {
gpio_set_level(GPIO_NUM_27, 0);
gpio_set_level(GPIO_NUM_26, 0);
ESP_LOGI(TAG, "Off, Off");
}
void secondState(void) {
gpio_set_level(GPIO_NUM_27, 1);
gpio_set_level(GPIO_NUM_26, 0);
ESP_LOGI(TAG, "On, Off");
}
void thirdState(void) {
gpio_set_level(GPIO_NUM_27, 0);
gpio_set_level(GPIO_NUM_26, 1);
ESP_LOGI(TAG, "Off, On");
}
void fourthState(void) {
gpio_set_level(GPIO_NUM_27, 1);
gpio_set_level(GPIO_NUM_26, 1);
ESP_LOGI(TAG, "On, On");
}
states.c
Now, let’s build our basic state machine. For our example, this state machine will count button presses and then reset after 3 presses. Maybe not a particularly exciting use for a state machine, but it illustrates the advantage of externalising your hardware outputs.
#include <assert.h>
#include "state_machine.h"
typedef struct DeviceState* DeviceStatePtr;
static DeviceStatePtr deviceState;
typedef void (*Action)(void);
static void setupFirstState(DeviceStatePtr deviceState);
static void setupSecondState(DeviceStatePtr deviceState);
static void setupThirdState(DeviceStatePtr deviceState);
static void setupFourthState(DeviceStatePtr deviceState);
struct DeviceState {
Action action;
void (*onButtonPress)(DeviceStatePtr);
};
static void setupFirstState(DeviceStatePtr deviceState) {
deviceState->action = firstState;
deviceState->onButtonPress = setupSecondState;
}
static void setupSecondState(DeviceStatePtr deviceState) {
deviceState->action = secondState;
deviceState->onButtonPress = setupThirdState;
}
static void setupThirdState(DeviceStatePtr deviceState) {
deviceState->action = thirdState;
deviceState->onButtonPress = setupFourthState;
}
static void setupFourthState(DeviceStatePtr deviceState) {
deviceState->action = fourthState;
deviceState->onButtonPress = setupFirstState;
}
state_machine.c
Event Queues
Alright, we have our state transitions defined and the functions that set our LEDs are isolated so that we can replace them. That just leaves the trigger for these transitions, a button which doesn’t exist yet. If we add an event handler to drive the state machine we can generate events without the button and still test all of our transitions. This means that we need to abstract away the interrupt caused by button presses into an event queue which is processed by a handler. The function triggered by the interrupt should only add an event to the queue, not impact the state machine directly. This will also make testing straightforward since our tests can just add to this queue whenever they need to.
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <esp_event_loop.h>
#include <driver/gpio.h>
#include "state_machine.h"
#define TAG "main.c"
#define ESP_INTR_FLAG_DEFAULT 0
static QueueHandle_t eventQueue;
void stateMachineTask(void *pvParameters) {
ESP_LOGI(TAG, "State machine started");
createDeviceState();
int event = 1;
for (;;) {
if (xQueueReceive(eventQueue, &event, portMAX_DELAY)) {
ESP_LOGI(TAG, "Event received");
triggerEvent();
}
}
vTaskDelete(NULL);
}
void app_main() {
ESP_LOGI(TAG, "App main has started");
eventQueue = xQueueCreate(10, sizeof(int));
xTaskCreate(&stateMachineTask, "State Machine", 1024, NULL, 1, NULL);
initialiseHardware(&eventQueue);
}
app_main.c
With our handler in place, we’ll need to add the trigger function to our state machine. This function simply calls the transitions we defined earlier based on the current state. We’ll also need a way to initialise the state machine so let’s add a function to create an instance of a device state which is set up as the first state.
void triggerEvent(void) {
deviceState->onButtonPress();
deviceState->action();
}
void createDeviceState() {
deviceState = malloc(sizeof *deviceState);
assert(deviceState);
if (deviceState) {
setupFirstState(deviceState);
}
}
state_machine.c
Finally, we need to add our event generator, the hardware interrupt which fires off these events each time our button is pressed. As discussed earlier, the only thing that a button press will actually do is generate an event on our event queue—something we can do ourselves during testing. And of course, we’ll need two outputs set so that we can see our counter being incremented.
#include <driver/gpio.h>
void IRAM_ATTR buttonHandler(void* arg) {
xQueueSendFromISR(eventQueue, NULL, NULL);
}
void initialiseHardware(xQueueHandle* events) {
eventQueue = *events;
gpio_config_t ioConfig;
ioConfig.intr_type = GPIO_PIN_INTR_DISABLE;
ioConfig.pin_bit_mask = 1 << 26 | 1 << 27;
ioConfig.mode = GPIO_MODE_OUTPUT;
ioConfig.pull_down_en = 0;
ioConfig.pull_up_en = 0;
gpio_config(&ioConfig);
ioConfig.intr_type = GPIO_PIN_INTR_POSEDGE;
ioConfig.pin_bit_mask = 1 << 21;
ioConfig.mode = GPIO_MODE_INPUT;
ioConfig.pull_down_en = 1;
ioConfig.pull_up_en = 0;
gpio_config(&ioConfig);
gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
gpio_isr_handler_add(21, buttonHandler, (void*) 21);
}
app_main.c
Conclusion
We’ve completed our state machine and it has an overall architecture which looks something like Figure 2 below.
A device that counts to 3 may not seem like a particularly useful endeavour, but this architecture can be applied to many other applications. Let’s say you’re developing a new IoT device. Events like WiFi connections and disconnections could be abstracted away into an event queue like this one allowing you to automate the testing of all your retry logic without needing to even set up a WiFi network. Maybe you decide to add an I2C sensor to this device to record the temperature. By externalising the functions which write to and read from the I2C bus you’ll be able to test your dataflow, conversion functions, and logging before you even have the device on hand. Unit testing functions which rely on hardware interactions is now simplified by mocking those interactions, either by reimplementing an external function or by adding events to our event queues. Perhaps most importantly, because you can test that the application logic is correct without the hardware, when you go to integrate the hardware you’ll know the source of all your bugs.
Header image courtesy of Unsplash