In my journey to build a system for automating our farm, I experimented with a wide range of sensors and devices. While there were notable differences among them, they also had enough common traits that I believe I was able to develop a framework in the piotserver platform flexible enough to support these devices.
It didn’t take long, though, to realize that what I needed was a plug-in architecture that would allow me, or anyone brave enough to experiment with a new device, to do so without recompiling the piotserver code.
Since the operating systems that run on the Raspberry Pi are based on Linux, it is possible to build software plug-ins using the dynamic linking loader functions dlopen() and dlsym() to connect with user-written shared object libraries (.so).
With a slight amount of hackery, you can even leverage the shared library linkage effectively when coding in the C++ environment.
Not only did this technique allow me to reduce the code size of the piotserver framework, but it also made it a lot easier to debug new plug-ins. All I had to do was write a simple validation shell for the plug-in before testing it with the server framework.
I sometimes interchange the term driver with plug-in, likely due to my past experience with writing operating system code 😎
I have written about a dozen plug-in modules for piotserver so far, and the code is available in my GitHub repository.
SHT25 - Digital humidity and temperature sensor
SHT30 - Digital humidity and temperature sensor
TMP10X - Digital Temperature Sensor
ADS1115 - Low-Power 16-Bit ADC
BME280 - Humidity, temperature, barometric pressure sensor
MCP23008 - 8-bit, general purpose, parallel I/O expansion
MCP3427 - 16 Bit A/D converter
PCA9536 - Remote 4-Bit I²C I/O Expander
PCA9671 - Remote 16-Bit I²C I/O Expander
TCA9534 - Remote 8-Bit I²C I/O Expander
QWIIC_RELAY - SparkFun Qwiic Relay
QwiicButton - SparkFun Qwiic Button
Rolling your own
In the previous article, I began describing how to configure the piotserver platform to communicate with various sensors and devices, and interact with them using a REST API. In this chapter, I would like to spend some time talking about how the server communicates with sensor plug-ins. I reckon that one of the best ways to explain it is to walk through the process of building your device/sensor plug-in.
If you look at the code for piotserver on GitHub, you will notice that it is written in C++23. In particular, I use the LLVM Clang toolchain. GCC is a perfectly acceptable alternative, but I found that compile times were much faster with Clang, especially when building and trying to shake out bugs on the Raspberry Pi Zero platform.
For our example, we will create a plug-in called SAMPLE, whose primary function is to keep track of the number of seconds since the server was started. The code for this example is available on GitHub.
Let’s also create a custom configuration file named “sample.props.json” that we will use to tell piotserver about the SAMPLE plug-in, and give the value returned from the plug-in the name of RUN_TIME and a data type of SECONDS.
{
"devices": [
{
"device_type": "SAMPLE",
"data_type": "SECONDS",
"key": "RUN_TIME",
"title": "Run Time"
}
]
}
We will use C++’s runtime polymorphism to create our SAMPLE_Device class, which will be derived from the virtual base class pIoTServerDevice.
#include "pIoTServerDevice.hpp"
using namespace std;
class SAMPLE_Device : public pIoTServerDevice{
public:
SAMPLE_Device(string devID, string driverName);
~SAMPLE_Device();
bool initWithSchema(deviceSchemaMap_t deviceSchema);
bool start();
void stop();
bool isConnected();
bool setEnabled(bool enable);
bool getValues( keyValueMap_t &);
private:
bool _isSetup = false;
time_t _startup_time;
string _resultKey; //SECONDS
};
However, since our code will be encapsulated in a Linux shared object library, we need to establish a linkage to our C++ class. Since C++ does not have a concept of virtual methods for class instantiation, we have to write a simple class factory function in C.
Here is how it works:
Upon startup, the piotserver program will scan the “plug-ins” subdirectory for files with the “.so” extension.
For each matching file, piotserver calls the dlopen() function, passing in the filepath. This will load the dynamic library file into memory. We use the RTLD_LAZY option as an optimisation so that only symbols that are referenced are resolved.
void *handle = dlopen(dllPath.c_str(), RTLD_LAZY);
If a valid handle is returned from dlopen(), piotserver then calls dlsym(), passing in the handle and the symbol name “factory”. If successful, dlsym() should return the address where that factory symbol is loaded into memory.
typedef pIoTServerDevice* factory_t(string devID, string driverName);
factory_t* factory = dlsym(handle, "factory");
The piotserver then invokes the factory function, which resides in the plug-in’s library file, passing a string with the plug-in name and a string with the plug-in’s internal ID number.
pIoTServerDevice* plugin = factory(deviceID, driverName);
This factory function in the plug-in should then create a new derived pIoTServerDevice object, which gets passed back to the piotserver
extern "C" pIoTServerDevice* factory(std::string devID,string driverName) {
pIoTServerDevice* newPlugin = new SAMPLE_Device(devID, driverName);
return newPlugin;
}
Creation and Destruction
As mentioned above, the factory function will instantiate a copy of the SAMPLE_Device class, using the C++ new operator. As a matter of convention, your constructor should call the inherited setDeviceID() method, passing in the devID and driverName strings that were passed in. Typically, we also set the inherited _deviceState to DEVICE_STATE_UNKNOWN at this time, too.
SAMPLE_Device::SAMPLE_Device(string devID, string driverName){
setDeviceID(devID, driverName);
_deviceState = DEVICE_STATE_UNKNOWN;
_isSetup = false;
}
Shortly after the class is created, piotserver will invoke the initWithSchema() method. The device schema is a C++ key-value map that stores information about the keys specified for that device from the configuration file “sample.props.json” we discussed above.
typedef struct deviceSchema_t {
string title;
valueSchemaUnits_t units = UNKNOWN;
....
} deviceSchema_t;
typedef std::map<std::string, deviceSchema_t> deviceSchemaMap_t;
In our example, there is a key called RUN_TIME, with units in SECONDS, and the title will be “Run Time”.
Our initWithSchema() function will use that information to associate the name of the key that we expect our plug-in to return the elapsed time in.
This is also a good time to set _deviceState to DEVICE_STATE_DISCONNECTED.
bool SAMPLE_Device::initWithSchema(deviceSchemaMap_t deviceSchema){
for(const auto& [key, entry] : deviceSchema) {
if(entry.units == SECONDS ){
_resultKey = key;
_isSetup = true;
return true;
}
}
_deviceState = DEVICE_STATE_DISCONNECTED;
return false;
}
Start and Stop
Once our plug-in has been initialized, piotserver will invoke the start() method. This is when your code should attempt to establish communication with the device. For example, if it were an I²C device, you would connect to it and perform any required initialization.
In our sample, all we need to do is record the start time and set the _deviceState to connected.
bool SAMPLE_Device::start(){
_startup_time = time(NULL);
_deviceState = DEVICE_STATE_CONNECTED;
return true;
}
Some devices might require specialized information. For example, the TMP10X temperature sensors have an I²C address. As I mentioned in part 8 of this series, we would add this to the config file entry for that device.
{
"address": "0x48",
"data_type": "TEMPERATURE",
"device_type": "TMP10X",
"key": "TEMP 48",
"title": "Temperature 48",
}
The plug-in can find this information in the _deviceProperties attribute of the pIoTServerDevice base class. I use the JSON for Modern C++ library by Niels Lohmann to hold this data. This library makes it easy to do something like the following code to extract the I²C address from the configuration.
string address = _deviceProperties["address"];
uint8_t i2cAddr = std::stoi(address.c_str(), 0, 16);
There is also a stop() method that piotserver will use when shutting down or pausing. Your code should use this to shut down its device.
void SAMPLE_Device::stop(){
_startup_time = 0;
_deviceState = DEVICE_STATE_DISCONNECTED;
}
There is also a function to tell the piotserver if your device is functioning. For now, let’s keep it simple.
bool SAMPLE_Device::isConnected(){
return true;
}
It is also possible to enable or disable a device through the REST API. Your code can use this to perform specialized tasks or initiate a start/stop operation. Or completely disregard it.
bool SAMPLE_Device::setEnabled(bool enable){
if(enable){
_isEnabled = true;
if( _deviceState == DEVICE_STATE_CONNECTED){
return true;
}
// force restart
stop();
bool success = start();
return success;
}
_isEnabled = false;
if(_deviceState == DEVICE_STATE_CONNECTED){
stop();
}
return true;
}
Getting the data
The real meat and potatoes of the plug-in are in the getValues() and setValues() methods. They both take a C++ key-value map, which I defined as follows:
typedef std::map<std::string, std::string> keyValueMap_t;
The main loop of the piotserver will periodically call each plug-in’s getValues() method. If the plug-in has data available, it should fill the results field using the corresponding key(s) passed in earlier to the initWithSchema() method. If we have no data, then we can return false as a result or not update the results.
In our sample, we need to subtract the current time from the start time and return the difference in string format, keyed to the string “RUN_TIME”. Remember, we stored that key earlier in the initWithSchema() method.
bool SAMPLE_Device::getValues (keyValueMap_t &results){
if(!isConnected())
return false;
time_t now = time(NULL);
results[_resultKey] = to_string(now - _startup_time);
return true;
}
Thus, when we make a REST call to the server (see the last article).
http://royal9:8081/values/?RUN_TIME
We will receive the following response.
{
"values": {
"RUN_TIME": {
"display": "20 Seconds",
"time": 1748899740,
"title": "Run Time",
"value": "20"
}
}
Sometimes data is received asynchronously. We might request data from a device, but it may not be available immediately. While the server architecture does not support asynchronous callbacks (at this time), the plug-in can optionally respond to a hasUpdates() method.
bool SAMPLE_Device::hasUpdates();
If your code returns false to this method, then the server will not call the getValues() method. It’s just an optimization, and you can ignore it too if you wish.
Setting Data
The setValues() method works similarly, except that we are passed a key-value map with the values we should set for each key. For example, with a relay, we might say something like “GREENHOUSE_LIGHT” with a value of “1” or “0”.
bool setValues(keyValueMap_t kv);
The pIoTServerDevice class supports a few additional details and optional methods. Still, most of these can be easily gleaned from the extensive examples of plug-ins I have made available in my GitHub repository.
If anything, the code serves as a good education on how to, or perhaps how not to, build a complex system in C++.
Next, I should write about what I call Pseudo devices, or software devices that build upon real devices. I should also discuss how we integrate automation into the devices.