Tutorial 10: Write a Reusable Driver (C++)
Turn a hardware interface into a reusable horus::Node that other projects can drop in. This tutorial builds a production-quality IMU driver.
What You'll Learn
- Driver as a self-contained
horus::Nodesubclass - Configuration via
horus::Params - Health monitoring with
Heartbeat - Diagnostic reporting with
DiagnosticStatus - Safe shutdown on hardware failure
The Driver Pattern
┌─────────────────────────────────────┐
│ ImuDriver : horus::Node │
│ │
│ init() → open device, configure │
│ tick() → read data, publish │
│ safe() → zero outputs, close │
│ │
│ Publishes: │
│ "imu.data" (Imu) │
│ "imu.heartbeat"(Heartbeat) │
│ "imu.status" (DiagnosticStatus)│
│ │
│ Params: │
│ imu.port = "/dev/ttyUSB0" │
│ imu.baudrate = 115200 │
│ imu.calibrate = true │
└─────────────────────────────────────┘
Complete Code
#include <horus/horus.hpp>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include <cmath>
using namespace horus::literals;
class ImuDriver : public horus::Node {
public:
ImuDriver(horus::Params& params)
: Node("imu_driver"), params_(params)
{
imu_pub_ = advertise<horus::msg::Imu>("imu.data");
hb_pub_ = advertise<horus::msg::Heartbeat>("imu.heartbeat");
status_pub_ = advertise<horus::msg::DiagnosticStatus>("imu.status");
}
void init() override {
const char* port = "/dev/ttyUSB0"; // from params in production
int baud = static_cast<int>(params_.get<int64_t>("imu.baudrate", 115200));
fd_ = open(port, O_RDWR | O_NOCTTY | O_NONBLOCK);
if (fd_ < 0) {
horus::log::error("imu", "Failed to open " + std::string(port));
publish_status(2, "Failed to open serial port");
return;
}
// Configure serial port (abbreviated)
// ...
connected_ = true;
horus::log::info("imu", "IMU connected on " + std::string(port));
publish_status(0, "Connected and calibrating");
if (params_.get<bool>("imu.calibrate", true)) {
calibrate();
}
}
void tick() override {
tick_count_++;
if (!connected_) return;
// Read raw data from IMU (non-blocking)
uint8_t buf[64];
int n = fd_ >= 0 ? read(fd_, buf, sizeof(buf)) : -1;
if (n > 0) {
parse_and_publish(buf, n);
consecutive_errors_ = 0;
} else {
consecutive_errors_++;
if (consecutive_errors_ > 100) { // 1 second at 100 Hz
horus::log::error("imu", "IMU read timeout — 100 consecutive failures");
horus::blackbox::record("imu", "Read timeout > 1s");
publish_status(2, "Read timeout");
connected_ = false;
}
}
// Heartbeat at 1 Hz
if (tick_count_ % 100 == 0) {
horus::msg::Heartbeat hb{};
std::strncpy(reinterpret_cast<char*>(hb.node_id), "imu_driver", 31);
hb.sequence = tick_count_ / 100;
hb.alive = connected_;
hb_pub_->send(hb);
}
// Status at 0.2 Hz
if (tick_count_ % 500 == 0) {
publish_status(connected_ ? 0 : 2,
connected_ ? "OK" : "Disconnected");
}
}
void enter_safe_state() override {
horus::log::warn("imu", "Safe state — closing device");
if (fd_ >= 0) { close(fd_); fd_ = -1; }
connected_ = false;
horus::blackbox::record("imu", "Safe state entered");
}
private:
void calibrate() {
// Read 100 samples, compute bias
horus::log::info("imu", "Calibrating (hold still)...");
// ... calibration logic ...
horus::log::info("imu", "Calibration complete");
publish_status(0, "Ready");
}
void parse_and_publish(const uint8_t* buf, int len) {
// Parse device-specific protocol into Imu message
horus::msg::Imu imu{};
imu.orientation[3] = 1.0; // identity quaternion
imu.angular_velocity[2] = 0.01; // simulated yaw rate
imu.linear_acceleration[2] = 9.81; // gravity
imu.timestamp_ns = 0;
// Apply calibration offset
imu.angular_velocity[2] -= gyro_bias_z_;
imu_pub_->send(imu);
}
void publish_status(uint8_t level, const char* msg) {
horus::msg::DiagnosticStatus status{};
status.level = level;
std::strncpy(reinterpret_cast<char*>(status.message), msg, 255);
status.message_len = std::strlen(msg);
status_pub_->send(status);
}
horus::Params& params_;
horus::Publisher<horus::msg::Imu>* imu_pub_;
horus::Publisher<horus::msg::Heartbeat>* hb_pub_;
horus::Publisher<horus::msg::DiagnosticStatus>* status_pub_;
int fd_ = -1;
bool connected_ = false;
int tick_count_ = 0;
int consecutive_errors_ = 0;
double gyro_bias_z_ = 0.0;
};
int main() {
horus::Params params;
params.set("imu.baudrate", int64_t(115200));
params.set("imu.calibrate", true);
horus::Scheduler sched;
sched.tick_rate(100_hz).name("imu_node").prefer_rt();
ImuDriver imu(params);
sched.add(imu)
.order(0)
.budget(2_ms)
.on_miss(horus::Miss::Warn)
.watchdog(5_s)
.build();
sched.spin();
}
What Makes a Good Driver
| Aspect | Implementation |
|---|---|
| Self-contained | All hardware access in one Node class |
| Configurable | Port, baudrate, calibration via Params |
| Monitored | Heartbeat + DiagnosticStatus published |
| Safe | enter_safe_state() closes device |
| Fault-tolerant | Consecutive error counting, auto-disable |
| Logged | horus::log for runtime, blackbox::record for crashes |
| Non-blocking | O_NONBLOCK on file descriptor |
Reusing the Driver
Other projects add it as a node:
// In another project's main.cpp
ImuDriver imu(params);
sched.add(imu).order(0).budget(2_ms).build();
// Any node can subscribe to imu.data
auto imu_sub = sched.subscribe<horus::msg::Imu>("imu.data");
Key Takeaways
- Drivers are
horus::Nodesubclasses — portable, reusable, testable - Publish 3 topics: data, heartbeat, status — lets monitoring work automatically
- Non-blocking I/O in
tick()— never block the scheduler - Count consecutive errors — disable after threshold (1 second typical)
enter_safe_state()closes hardware safely- Params for configuration — no hardcoded values