Plugins

Covers Engine Plugin SDK 2.0.0 · Interface Plugin SDK 0.2.0

Building a Hardware Driver

Build a module-backed read/control/write driver with mock discovery, fixed channels, and native DCode.

This guide builds a complete driver shape before there is any hardware: one long-lived mock module owns simulated device data and connection state; a read task copies that state into DARTWIC; a write task consumes a command from DARTWIC and applies it to the module; and a native DCode task calculates the command between them. The example is included in the current engine-plugin SDK, so the code below is compiled with the SDK.

The pattern mirrors the Modbus TCP plugin: a module owns the connection, separate configured read and write tasks use that module, and a plugin-owned discovery loop recommends the module and its tasks. Replace only the module’s simulated I/O with your protocol implementation; keep the task and channel contracts clear.

What you are building

MockDeviceDiscovery loop (1 Hz) -- mute handle --> built-in discovery UI
        |                                       (mute / unmute candidate)
        +--> offers mock_device_1 only when it is not configured
                         |
                         v
                 ExampleDeviceModule
              owns connection + device data
                         |
        React task editor selects compatible module + bindings
                         |
       +-----------------+-----------------+
       |                                   |
       v                                   v
mock_device_1_read (10 Hz)        mock_device_1_write (10 Hz)
publishes measurement, applied    reads selected command and applies it
       |                                   ^
       v                                   |
        mock_control, Native C++ (10 Hz) --+

TaskGroup mock_cycle: read -> control -> write

The module is the source of truth for the simulated device. The read task moves device state into channels. The write task moves the command channel to the device. mock_device_applied is simulated feedback, not proof that a physical actuator moved.

Use Deterministic Automation as the timing and failure contract. Fixed channels and native execution make a repeated numeric path more predictable; they do not create a safety case or a hard real-time guarantee.

Use the fixed/native pattern only when your measured driver and control path needs it. A dynamic-channel driver with Lua automation is appropriate for most device integration, changing mappings, setup flows, and ordinary automation. For a known, frequently repeated numeric cycle—especially a tight 100–1,000 Hz acquisition/control/output loop—prepare fixed handles and buffers, use Native C++ DCode where its subset fits, and qualify the engine host, driver, transport, and physical feedback together. Linux by itself is not a real-time guarantee; use an actual real-time-capable, qualified deployment when the physical deadline requires one.

1. Create a module that owns the device

A module is a long-lived configured object. It is the right home for a TCP socket, serial handle, reconnect state, protocol buffers, or—in this mock example—the in-memory stand-in for all of that. Do not put a separate connection inside each task.

The SDK example’s module stores the measurement, commanded value, and applied value behind one mutex. ensureConnected() is deliberately simple, but its call site is the same place a real driver would connect or respect its reconnect policy.

struct MockDeviceData {
    double measurement = 0.0;
    double commanded = 0.0;
    double applied = 0.0;
};

class ExampleDeviceModule final : public DARTWIC::Modules::BaseModule {
public:
    ExampleDeviceModule(nlohmann::json cfg, DARTWIC::API::SDK_API* api)
        : BaseModule(std::move(cfg), api) {}

    bool ensureConnected() {
        std::scoped_lock lock(mutex_);
        connected_ = true;
        return connected_;
    }

    MockDeviceData readDevice() {
        std::scoped_lock lock(mutex_);
        ++data_.measurement;
        return data_;
    }

    void writeDevice(double command) {
        std::scoped_lock lock(mutex_);
        data_.commanded = command;
        data_.applied = command;
    }

private:
    std::mutex mutex_;
    bool connected_ = false;
    MockDeviceData data_;
};

Register the module type when the plugin loads, and return it from createModule:

dartwic->registerModuleType({
    .id = "example_device",
    .name = "Example Device"
});

DARTWIC::Modules::BaseModule* ExampleDevicePlugin::createModule(
    const std::string& module_type_id,
    nlohmann::json cfg,
    DARTWIC::API::SDK_API* api
) {
    if (module_type_id != "example_device") return nullptr;
    return new ExampleDeviceModule(std::move(cfg), api);
}

For a real device, use the module configuration to hold endpoint, baud rate, unit ID, timeout, and protocol options. Give the module a bounded connect/read/write policy and publish an explicit health or connection channel; do not treat a cached numeric value as a connection indication.

2. Offer one mock module through discovery

Discovery is a plugin loop, not a task that runs a device cycle. The mock discovery loop below runs at 1 Hz. It offers exactly one candidate and stops offering it once mock_device_1 exists. Use a NotificationMuteHandle rather than a one-time isNotificationMuted check: refresh() observes mute and unmute transitions. Here both callbacks clear the local offer state. A mute suppresses the next offer; an unmute causes one fresh offer on the next discovery tick.

class MockDeviceDiscovery {
public:
    explicit MockDeviceDiscovery(DARTWIC::API::SDK_API* api)
        : api_(api), mute_handle_("device-discovery:mock-device-1",
            [this] { offered_ = false; },
            [this] { offered_ = false; }) {}

    void tick() {
        constexpr auto instance_name = "mock_device_1";
        constexpr auto discovery_id = "mock-device-1";
        if (api_->getModuleInstance(instance_name)) {
            offered_ = false;
            return;
        }
        if (mute_handle_.refresh(*api_) || offered_) return;

        api_->requestInterfaceUi("dartwic.module-discovery", {
        {"presence", {{"available", true}}},
        {"discovery_id", discovery_id},
        {"device_type", "example_mock_device"},
        {"display_name", "Example Mock Device"},
        {"endpoint", {{"host", "simulated"}, {"port", 1}}},
        {"channels", nlohmann::json::array({
            {{"name", "mock_device_measurement"}, {"direction", "read"}, {"units", "test units"}},
            {{"name", "mock_device_command"}, {"direction", "write"}, {"units", "test units"}},
            {{"name", "mock_device_applied"}, {"direction", "read"}, {"units", "test units"}}
        })},
        {"provisioning", {
            {"module_type", "example_device"},
            {"suggested_instance_name", instance_name},
            {"tasks", nlohmann::json::array({
                {{"name_suffix", "_read"}, {"task_type", "mock_read"},
                 {"arguments", {
                    {"channel_prefix", "mock_device"},
                    {"measurement_channel", "mock_device_measurement"},
                    {"applied_channel", "mock_device_applied"},
                    {"connected_channel", "mock_device_connected"}
                 }}},
                {{"name_suffix", "_write"}, {"task_type", "mock_write"},
                 {"arguments", {
                    {"channel_prefix", "mock_device"},
                    {"command_channel", "mock_device_command"}
                 }}}
            })}
        }}
    }, {
        {"request_key", discovery_id},
        {"merge_key", "module-discovery"},
        {"silenceable", true},
        {"mute_scope", "engine"},
        {"notification_id", "device-discovery:mock-device-1"},
        {"reopen_completed", true}
        });
        offered_ = true;
    }

private:
    DARTWIC::API::SDK_API* api_;
    DARTWIC::API::NotificationMuteHandle mute_handle_;
    bool offered_ = false;
};

auto mock_device_discovery = std::make_shared<MockDeviceDiscovery>(dartwic);
dartwic->registerLoop("mock_device_discovery", "Example Mock Device Discovery", {
    .on_loop = [mock_device_discovery]() { mock_device_discovery->tick(); },
    .target_frequency_hz = 1.0
});

The suggestion is intentionally small: one module plus mock_device_1_read and mock_device_1_write. The provisioning operation injects the newly created module_instance_name into each linked task. The suggestion also supplies the default read/output and write/command bindings, so the created tasks are immediately configured for the shown test channels.

For real discovery, replace the constant candidate with a bounded scan or device announcement. Give every physical candidate a stable discovery ID and retain one mute handle per ID (for example, in a map). On mute, withdraw any local presence or queued work for that candidate; on unmute, make it eligible to announce again. A discovery result is not proof that the device is healthy or safe to command.

3. Bind each task to one compatible module and its own channels

Both task types require module_instance_name and accept explicit channel bindings. channel_prefix remains only a useful default generator; it is not the runtime contract. The engine resolves the selected module and dynamically checks that it is the expected module type. That check remains necessary even though the UI filters its list: configuration can also come from provisioning, saved files, or an API call.

The bindings intentionally flow in only one direction per task:

TaskModule selectionFixed channel bindingsRole
mock_readOne example_device instancemeasurement_channel, applied_channel, connected_channelDevice/module -> DARTWIC outputs
mock_writeOne example_device instancecommand_channelDARTWIC command -> device/module input

During on_configure, create or preserve only the fixed channels used by that task and declare fixed inputs only for the writer. During on_start, resolve only those direct bindings into a task-local runtime state.

void configureMockDriverTask(
    DARTWIC::API::SDK_API* api,
    DARTWIC::API::TaskRuntime& runtime,
    bool is_write
) {
    using namespace DARTWIC::API;
    auto module = mockModule(api, runtime); // checks module_instance_name and type
    const auto channels = mockChannels(runtime);
    const auto task_channels = is_write
        ? std::vector<std::string>{channels.command}
        : std::vector<std::string>{channels.measurement, channels.applied, channels.connected};
    for (const auto& channel : task_channels) {
        module->dartwic->insertChannelField(channel, ChannelField::VALUE, 0.0, ChannelStorage::Fixed);
        module->dartwic->upsertChannelField(channel, ChannelField::RECORD_MODE, RecordMode::Never, ChannelStorage::Fixed);
    }
    runtime.setFixedInputChannels(
        is_write ? std::vector<std::string>{channels.command} : std::vector<std::string>{});
}

insertChannelField provides the initial value only when a channel is missing. It does not reset an existing command during a task reconfiguration. setFixedInputChannels must be called during on_configure, before the task runs. It tells CAESAR to capture the selected write task’s command at release; it does not claim authority over that command.

Resolve fixed channels and allocate task-local arrays during on_start, then keep that state in the task runtime. In a driver with register maps, this is also where you build packet layouts and reusable protocol buffers.

struct MockDriverTaskState {
    std::shared_ptr<ExampleDeviceModule> module;
    DARTWIC::API::FixedChannelBatch inputs;
    DARTWIC::API::FixedChannelBatch outputs;
    std::array<double, 1> command{};
    std::array<double, 3> values{};
};

// The write task resolves {command_channel} as inputs.
// The read task resolves {measurement_channel, applied_channel,
//                         connected_channel} as outputs.

This is the fixed-channel rule: declare names, resolve handles, and reserve buffers before periodic execution. The same task needs to reconfigure and re-resolve if the mapping changes; stale handles deliberately fall back rather than silently attaching to a different fixed slot.

4. Show the module selector and bindings in the React task editor

The built-in dartwic.module-discovery screen already presents the candidate and accepts its provisioning plan; it does not need a plugin-specific React screen. A plugin React editor is useful for creating or editing a task later, because it can restrict module selection and make every read/write binding explicit.

The example plugin registers editors for example_device_plugin.mock_read and example_device_plugin.mock_write. Its ModuleInstanceSelect only exposes example_device_plugin modules with local type example_device. The read editor uses ChannelComboBox in write mode because it publishes device data; the write editor uses read mode because it consumes a command.

function MockDriverTaskConfig({task, onSaved, onClose}) {
    const {operation} = useTaskConfigBridge({task, onSaved, onClose});
    const isRead = task.task_type === "example_device_plugin.mock_read";
    const [moduleInstanceName, setModuleInstanceName] = React.useState(
        task.arguments?.module_instance_name || ""
    );
    const [measurement, setMeasurement] = React.useState(task.arguments?.measurement_channel || "");
    const [applied, setApplied] = React.useState(task.arguments?.applied_channel || "");
    const [connected, setConnected] = React.useState(task.arguments?.connected_channel || "");
    const [command, setCommand] = React.useState(task.arguments?.command_channel || "");

    const payload = isRead
        ? {module_instance_name: moduleInstanceName,
           measurement_channel: measurement, applied_channel: applied, connected_channel: connected}
        : {module_instance_name: moduleInstanceName, command_channel: command};

    return <>
        <ModuleInstanceSelect
            pluginId="example_device_plugin"
            moduleTypeIds={["example_device"]}
            value={moduleInstanceName}
            onChange={setModuleInstanceName}
            showStatus
        />
        {isRead ? <>
            <ChannelComboBox mode="write" overrideValue={measurement} onValueChange={setMeasurement} />
            <ChannelComboBox mode="write" overrideValue={applied} onValueChange={setApplied} />
            <ChannelComboBox mode="write" overrideValue={connected} onValueChange={setConnected} />
        </> : <ChannelComboBox mode="read" overrideValue={command} onValueChange={setCommand} />}
        <button onClick={() => operation("dartwic/create-task", {
            portal_name: task.portal, task_name: task.name, task_type: task.task_type, arguments: payload
        })}>Save bindings</button>
    </>;
}

The full implementation also validates every required selection, normalizes selected channel references to names, and closes only after the operation succeeds. This task-to-module-and-channel design prevents a read task from silently publishing one device’s data to a channel paired with another device’s writer.

5. Create an explicit read task

The read task gets the module once during on_start, then on every release reads the device state and publishes measurement, applied feedback, and connection status through one prepared fixed batch. A real readDevice() would read and decode a bounded device response before filling the same reusable array.

DARTWIC::API::TaskTypeDefinition mock_read;
mock_read.metadata.structure = DARTWIC::API::TaskStructure::Periodic;
mock_read.metadata.default_arguments = {
    {"module_instance_name", ""}, {"channel_prefix", "mock_device"},
    {"measurement_channel", "mock_device_measurement"},
    {"applied_channel", "mock_device_applied"},
    {"connected_channel", "mock_device_connected"}
};
mock_read.on_configure = [this](const auto&, DARTWIC::API::TaskRuntime& runtime) {
    configureMockDriverTask(dartwic, runtime, false);
};
mock_read.on_start = [this](const auto&, DARTWIC::API::TaskRuntime& runtime) {
    runtime.setTypedRuntimeContext("mock-driver-read", createMockDriverRuntime(dartwic, runtime, false));
};
mock_read.on_task = [](const auto&, DARTWIC::API::TaskRuntime& runtime, double) {
    const auto state = runtime.getTypedRuntimeContext<MockDriverTaskState>("mock-driver-read");
    if (!state || !state->module || !state->module->ensureConnected()) return;
    const auto device = state->module->readDevice();
    state->values = {device.measurement, device.applied, 1.0};
    state->module->dartwic->upsertFixedChannelValues(state->outputs, state->values);
};
dartwic->registerTaskType("mock_read", "Mock Device Read", std::move(mock_read));

The read task publishes measurements. It should not claim the command channel merely because the same hardware driver can also write an output. Keep validity, timestamp/freshness, transport error, and actual feedback distinct from the measurement value in a production driver.

6. Create an explicit write task

The write task’s fixed input snapshot captures the command at its release. It reads that captured command into the preallocated array, then encodes and sends it through the module. In the mock module, sending simply copies it to applied.

DARTWIC::API::TaskTypeDefinition mock_write;
mock_write.metadata.structure = DARTWIC::API::TaskStructure::Periodic;
mock_write.metadata.default_arguments = {
    {"module_instance_name", ""}, {"channel_prefix", "mock_device"},
    {"command_channel", "mock_device_command"}
};
mock_write.on_configure = [this](const auto&, DARTWIC::API::TaskRuntime& runtime) {
    configureMockDriverTask(dartwic, runtime, true);
};
mock_write.on_start = [this](const auto&, DARTWIC::API::TaskRuntime& runtime) {
    runtime.setTypedRuntimeContext("mock-driver-write", createMockDriverRuntime(dartwic, runtime, true));
};
mock_write.on_task = [](const auto&, DARTWIC::API::TaskRuntime& runtime, double) {
    const auto state = runtime.getTypedRuntimeContext<MockDriverTaskState>("mock-driver-write");
    if (!state || !state->module || !state->module->ensureConnected()) return;
    state->module->dartwic->queryFixedChannelValues(state->inputs, state->command);
    state->module->writeDevice(state->command[0]);
};
dartwic->registerTaskType("mock_write", "Mock Device Write", std::move(mock_write));

queryFixedChannelValues uses the declared snapshot during a task release. upsertFixedChannelValues preserves the normal command-authority check; neither API claims ownership. Your automation task claims the command with DCode’s command statement. The driver consumes it and reports device feedback separately.

7. Provision the mock, add native control, and order the cycle

Build and install the current example plugin, then wait for Example Mock Device Discovery to offer Example Mock Device. Accept its recommendation to create mock_device_1, mock_device_1_read, and mock_device_1_write.

Set both plugin tasks to 10 Hz. Create mock_control.dcode, select DCode · Native C++, then build and activate it:

task_periodic mock_control 10 normal:
    task:
        local measured = |mock_device_measurement|
        local desired = measured * 2.0
        command |mock_device_command| = desired

task_group mock_cycle:
    run: mock_device_1_read
    run: mock_control
    run: mock_device_1_write

Set mock_control to 10 Hz as well. The group has no separate frequency: it releases members when they are due. Because all three members are 10 Hz, each due group cycle has this order:

  1. mock_device_1_read publishes the next simulated measurement and the previously applied command.
  2. mock_control captures that measurement and commands twice its value.
  3. mock_device_1_write captures that command and applies it to the module.

Start all three tasks. In Channel Search, watch the recommended test channels:

ChannelExpected behavior
mock_device_measurementIncreases once per read release.
mock_device_commandBecomes twice the measurement from the control release.
mock_device_appliedReports the command that the next read observed from the module.
mock_device_connectedIs 1 while the mock module’s connection succeeds.

The display is asynchronous, so it can show values from adjacent cycles. Verify the relation over time, task diagnostics, and group outcomes rather than expecting one screen refresh to be an atomic device snapshot.

Make the path predictable—and state what it cannot promise

For repeated control work, fixed channels and Native C++ DCode exist first to remove recurring allocation and storage-growth work from the declared read/control/write cycle. Fixed slots, resolved bindings, task arrays, output staging, and normal publication resources are prepared before release; avoiding repeated fixed-name lookup and Lua interpretation are additional benefits. They also give each task a coherent declared fixed-input view. These are useful reductions in recurring work, not a claim that every callback is allocation-free or meets a deadline.

Keep the repeated callbacks short and bounded. Resolve channel handles, allocate arrays, configure packet layouts, compile native DCode, and establish connections before starting the periodic tasks. Do not make telemetry, recording, UI, Share, or observer delivery a synchronous control step. Inline calculations still run before the writing task returns; their wait and execution time belong to that task’s measured duration. Driver I/O and locks are allowed when the protocol requires them, but their bounded timeout and failure response are part of the read/control/write budget.

A TaskGroup promises order, not success or freshness. If the read callback fails, later members currently remain eligible to run. Publish and check validity/freshness, choose the device-specific output response, and inspect member_last_outcomes, missed cycles, callback duration, and connection/feedback state. Deterministic Automation explains the three independent requirements—periodic execution, health response, and deterministic composition—and the qualification checklist.

For exact SDK signatures, see the fixed-channel API, plugin loops, and modules. For native DCode setup and supported language features, use Native C++ Execution.