APIC – Drivers and Streams development guide

Drivers and Streams Architecture „ development guide/p>

Driver & Stream Architecture

This section explains the recommended APIC architecture for implementing hardware drivers and their corresponding stream logic. In short: streams are the interface with the ICD structures (translating raw bytes into ICD-compliant blocks consumed by models and states), drivers are the bridge between streams and hardware, and the driver's API layer is the actual low-level interface to the hardware vendor's SDK. Both streams and drivers live under the project's interfaces/ folder.

See How to Build an APIC System for Software Engineers for where defining the CoreEngine's external interfaces fits into the overall system-engineering workflow, or How to Interact with External Devices for a practical walkthrough of wiring up a real device.

Driver & Stream Folder Layout

Each driver gets its own folder under interfaces/Drivers/, split into two layers:

  • The driver layer (the folder's top level) — a CDriverBase subclass that Streams talk to directly (Send(), Recv(), lifecycle callbacks).
  • The API layer (the api/ subfolder) — a separate shared library implementing the device/vendor-specific API behind an interface header. The driver layer loads this library at runtime through a Loader class.
interfaces/
 └── Drivers/
     ├── DriversProj.cpp            // registers all drivers
     ├── DriversProj.h
     └── SampleDevice/              // one folder per driver
         ├── SampleDevice.cpp           // driver layer — talks to Streams
         ├── SampleDevice.h
         ├── SampleDeviceDriverAPI.h    // API interface — add new API methods here
         ├── SampleDeviceLoader.cpp     // loads the API shared library at runtime
         ├── SampleDeviceLoader.h
         └── api/                       // separate shared library implementing the API
             ├── CMakeLists.txt
             ├── SampleDeviceAPI.cpp
             └── SampleDeviceAPI.h
Streams implementation is unchanged — only the location moved, to interfaces/Streams/<StreamName>/ (e.g. interfaces/Streams/SampleDevice/SampleDeviceStream.cpp), alongside a project-wide StreamsProj.cpp/.h.

1. Driver Responsibilities

The driver layer is a CDriverBase subclass that Streams talk to. It doesn't access hardware or a vendor SDK directly — instead it loads the driver's API layer (a separate shared library) and forwards calls to it. Its responsibilities:

  • Load the API shared library and create the API object (InitDriver())
  • Send raw frames by calling into the loaded API object (Send())
  • Receive raw frames by calling into the loaded API object (Recv())
  • Provide driver-related configuration (FillInProperties())
  • Handle lifecycle events (OnInit, OnReset, OnStop)
// SampleDevice.cpp – Driver layer
CSampleDeviceDriver::CSampleDeviceDriver(const char* streamName)
    : CDriverBase(streamName, "1.00", "SampleDeviceAPI", "")
{
    InitDriver();
}

// One-time driver init: load the API shared library and create the API object
void CSampleDeviceDriver::InitDriver()
{
    SampleDeviceAPILoader = std::make_unique<CSampleDeviceAPILoader>(name.c_str(), APIInterfaceLibName.c_str());

    std::string error;
    SampleDeviceAPIObj.reset(SampleDeviceAPILoader->InitApiWrapper(error));
    if (SampleDeviceAPIObj.get() == nullptr) {
        CSimReports::Report(CSimReports::eError, "SampleDeviceDriver: Failed to load SampleDeviceAPI. %s\r\n", error.c_str());
        return;
    }
}

CDriverBase::EIOResult CSampleDeviceDriver::Send(const uint8_t* raw, uint64_t size)
{
    // Forward to the API object, e.g. SampleDeviceAPIObj->Send(raw, size)
    return CDriverBase::EIOResult::eNoIO;
}

CDriverBase::EIOResult CSampleDeviceDriver::Recv(uint8_t* raw, uint64_t size)
{
    // Forward to the API object, e.g. SampleDeviceAPIObj->Recv(raw, size)
    return CDriverBase::EIOResult::eNoIO;
}
The third constructor argument ("SampleDeviceAPI") is the API library's base name — it must match the add_library(...) target name in the API's CMakeLists.txt so the driver layer can locate and load the built shared library at runtime.

2. Driver API Layer

The api/ subfolder builds as its own shared library — a separate CMake target — implementing the device/vendor-specific API behind an interface declared in <DriverName>DriverAPI.h. Keeping it separate lets you rebuild or swap the low-level vendor SDK integration independently of the driver layer that talks to Streams.

API interface

Declare the API surface as a pure-virtual interface. It ships with a minimal generic set of lifecycle methods — add whatever data-transfer methods your device actually needs (e.g. a Send/Recv or Read/Write pair); the driver layer calls into these.

// SampleDeviceDriverAPI.h – API interface
class ISampleDeviceDriver : public IApi
{
public:
    ISampleDeviceDriver(const std::string& streamName) {}
    virtual ~ISampleDeviceDriver() = default;

    // Configuration
    virtual inline bool Init() = 0;
    virtual bool Open(const char* deviceID) = 0;
    virtual bool Close() = 0;

    // Control
    virtual bool Start() = 0;
    virtual bool Stop() = 0;

    // Status
    virtual bool isOpen() = 0;
    virtual bool GetStatus(std::string& text) const = 0;

    // Add API functions for the driver here, such as Send()/Recv(), specific to this device
};

API implementation

Implement the interface in api/<DriverName>API.cpp/.h and export a factory function so the driver layer's Loader can create an instance from the built shared library:

// api/SampleDeviceAPI.cpp – API implementation
extern "C" EXPORT IApi* create_api() {
    return new CSampleDeviceAPIDriver();
}

bool CSampleDeviceAPIDriver::Open(const char* deviceID) {
    // Implement logic to open the communication port
    return true;
}
// ... Close(), Start(), Stop(), GetStatus(), and any custom methods you added
The generated api/ folder is a template: file and class names use a {{DriverAPIName}} placeholder. When creating a new driver, copy the folder, rename the files, and replace {{DriverAPIName}} throughout with your driver's name (e.g. SampleDevice).

API library build

Each api/ folder has its own CMakeLists.txt that builds a shared library and copies it next to the main executable:

add_library(SampleDeviceAPI SHARED
    SampleDeviceAPI.cpp
)
target_compile_features(SampleDeviceAPI PUBLIC cxx_std_17)

add_custom_command(TARGET SampleDeviceAPI POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
    $<TARGET_FILE:SampleDeviceAPI>
    $<TARGET_FILE_DIR:simcore>
)
The library target name (SampleDeviceAPI) must match the API library base name passed to CDriverBase's constructor in the driver layer, so the Loader can find and load it at runtime.

Loader

The Loader class (in the driver folder, not api/) dynamically loads the API shared library and hands back a pointer to the interface:

// SampleDeviceLoader.cpp
ISampleDeviceDriver* CSampleDeviceAPILoader::InitApiWrapper(std::string& error)
{
    return (ISampleDeviceDriver*)LoadAPI(error);
}

3. Stream Responsibilities

Streams translate raw driver messages into structured ICD blocks. They are the bridge between hardware bytes and internal logic models.

  • Load configuration for each block
  • Declare ICD block layout and sizes
  • Trigger models when new data is available
  • Handle receive callbacks and model triggers
// stream.cpp – Sample implementation
void CSampleDeviceStream::SetupStream(const CStreamDriver* pStreamDriver)
{
    // Configure max message sizes or block allocations here
}

bool CSampleDeviceStream::ReadMsg()
{
    auto pDriver = (CDriverBase*)pStreamDriver->pDriver;
    // Iterate ICD blocks and perform Recv() calls here
    return true;
}

4. Data Flow (Driver ↔ Stream ↔ ICD ↔ Models)

Data flows both ways through the same Driver/Stream pair: inbound bytes from hardware are received and delivered up to Models, and outbound data published by Models is sent back down to hardware.

Hardware Device Driver Stream Models / States Inbound — Recv() — Driver.Recv() → Stream.ReadMsg() → Model trigger Outbound — Model publishes → Stream builds message → Driver.Send() → hardware

5. Best Practices

  • Keep drivers strictly deterministic.
  • Avoid dynamic allocations inside Send() or Recv().
  • Streams must validate message sizes to prevent memory corruption.
  • Use receiveCB only when callbacks are necessary.
  • Trigger models only after a full ICD block is validated.
  • Keep the API layer free of engine/ICD knowledge — it should only wrap the vendor SDK or hardware protocol, not know about streams or blocks.

6. Integration Instructions

To register the driver, add it to interfaces/Drivers/DriversProj.cpp:

// interfaces/Drivers/DriversProj.cpp
CDrivers::RegisterDriver<CSampleDeviceDriver>(CSampleDeviceDriver::getName());

Make sure the driver's api/ folder is included in the CMake build so its shared library is compiled and copied next to the main executable — add it as a subdirectory from the project's top-level CMakeLists.txt:

add_subdirectory(interfaces/Drivers/SampleDevice/api)

7. Example Stream Processing Loop

for (auto& block : StreamBlocks)
{
    auto result = pDriver->Recv(block->rawPtr, block->rawSizeBytes);
    if (result > EIOResult::eNoIO)
    {
        // Successful receive
        // CallOnReceiveCB(block,...);
        // SetModelTrigger(block);
    }
    else if (result < EIOResult::eNoIO)
    {
        UpdateBlockPropertiesOnStreamError(block);
    }
}

8. Scheduling Diagram

View Core Scheduling Flow Driver Layer Stream Parser ICD Block Models Hardware I/O Validate & Split Model Triggers

9. Lockless SPSC Queue Template

Show SPSC Code
template <typename T, size_t N>
class SPSCQueue {
public:
    bool push(const T& v) {
        const auto next = (head_ + 1) % N;
        if (next == tail_) return false;
        buffer_[head_] = v;
        head_ = next;
        return true;
    }

    bool pop(T& out) {
        if (tail_ == head_) return false;
        out = buffer_[tail_];
        tail_ = (tail_ + 1) % N;
        return true;
    }

private:
    T buffer_[N];
    size_t head_ = 0;
    size_t tail_ = 0;
};

10. Multi‑Rate Scheduler Example

Show Scheduler Code
struct ScheduledTask {
    int rateHz;
    std::function<void()> callback;
    int accumulator = 0;
};

void RunScheduler(std::vector<ScheduledTask>& tasks, int baseHz) {
    const int step = 1000 / baseHz;
    for (auto& t : tasks) {
        t.accumulator += step;
        if (t.accumulator >= (1000 / t.rateHz)) {
            t.accumulator = 0;
            t.callback();
        }
    }
}

11. Determinism Checklist

  • No dynamic allocations inside time‑critical loops.
  • No locks or mutexes in high‑rate threads.
  • Use fixed‑size buffers everywhere.
  • Ensure driver read/write sizes are constant per block.
  • Use monotonic time measurements.