Plugins

Covers Engine Plugin SDK 2.0.0 · Interface Plugin SDK 0.2.0

Engine Plugin

Follow the Engine plugin entry point, registration flow, and SDK services.

An Engine plugin extends DARTWIC with native C++ code. It can supply reusable work, script functions, device connections, or communication features. Choose the contributions your plugin needs; a plugin does not have to include a module or a hardware driver.

Start from the example plugin. The source paths below belong to your plugin repository. Keep the starter’s SDK, entry-point exports, and build setup while adding your implementation.

Plugin Config

The root plugin.json identifies your plugin and tells the host which sides it contains. For the combined Engine/Interface examples on these pages:

{
  "id": "example_device_plugin",
  "name": "Example Device Plugin",
  "description": "Native behavior and its operator tools.",
  "author": "Your team",
  "version": "1.0.0",
  "minEngineVersion": "2.0.0",
  "minInterfaceVersion": "2.0.0",
  "contains_engine_plugin": true,
  "contains_interface_plugin": true
}

Keep contains_engine_plugin: true; set contains_interface_plugin: false if you are not shipping UI. Set each minimum version to the oldest host you actually support. The manifest guide owns field definitions and package layout.

Module types can also reference bundled module_config.json and default_parameters.json through their registration’s config_path and default_parameters_path. These describe the type and its default parameters. A user’s module instance and task arguments are separate workspace configuration; Building a Hardware Driver shows those files when your plugin needs them.

Plugin Lifecycle

The Engine loads the native library, checks its SDK ABI export, and calls createPlugin to construct your BasePlugin subclass. It then calls onPluginLoaded(), the main hook for registering what your plugin provides.

Load library → Check ABI → createPlugin → onPluginLoaded
                                           └─ Register types and handlers
                                                └─ Runtime calls them as needed

In the starter, this lives in engine/src/example_device_plugin.cpp. BasePlugin keeps the supplied SDK_API* as dartwic. Use it to register features inside onPluginLoaded() and to access Engine services from their callbacks. Keep the createPlugin and dartwicEngineSdkAbiVersion exports; BasePlugin documents that boundary.

The starter already supplies the exports below. Keep them while changing your plugin class and its registrations:

DARTWIC_PLUGIN_EXPORT uint32_t dartwicEngineSdkAbiVersion() noexcept {
    return DARTWIC::Plugins::ENGINE_PLUGIN_SDK_ABI_VERSION;
}

DARTWIC_PLUGIN_EXPORT DARTWIC::Plugins::BasePlugin* createPlugin(
    nlohmann::json cfg, DARTWIC::API::SDK_API* api) {
    return new Example::ExampleDevicePlugin(std::move(cfg), api);
}

When the Engine constructs your plugin, BasePlugin::config receives the manifest merged with effective plugins.<plugin-id> settings. This is plugin-wide configuration; createModule receives a particular module instance’s configuration. Use Storage and Settings for scoped settings rather than creating another configuration store.

Uninstalling the Engine side queues removal for the next Engine restart. BasePlugin has no unload callback. Release plugin-owned resources in your subclass’s destructor, including stopping and joining any threads you created. Keep task cleanup in its on_end / cleanup callbacks and module resources with the module.

Uninstall leaves saved workspace documents and separately stored settings and assets in place; Bundled assets are removed with the package; Storage and Settings explains those storage lifetimes.

Plugin Registrations

For the full current API, use the Engine SDK reference and the register* declarations in your bundled engine/include/sdk/sdk_api.h. The examples below illustrate the main registration patterns; check your target SDK for available methods and exact fields.

Registration describes capabilities available while your plugin is loaded. The Engine calls their handlers when work is configured, scheduled, or requested. Add registrations inside onPluginLoaded() alongside the starter features you keep.

ContributionWhat it addsSDK entry point
Task typeReusable work with its own configured instances and lifecycle.registerTaskType
DCode functionA native helper callable from DCode scripts.registerDCodeFunction
Module typeConfigured instances owning a device connection or shared service.registerModuleType
Plugin loopBackground plugin work, such as discovery or connection monitoring.registerLoop
OperationA request/response handler that clients or scripts can call.registerOperation
Telemetry topicCustom updates published to subscribed clients and peers.registerTelemetry
Device peerTranslate a device protocol using the Engine-supplied Peer and EngineProtocol.registerPeer
DARTWIC transportCarry standard DARTWIC peer messages over a custom link.registerPeerTransport

Local registration IDs are qualified with your plugin ID. For example, registering echo in the starter produces example_device_plugin.echo.

TEMPEST operations

A TEMPEST operation exposes a request/response handler to clients, the Interface, and script calls. This adds example_device_plugin.echo, with argument metadata for callers and operator tools:

void Example::ExampleDevicePlugin::onPluginLoaded() {
    dartwic->registerOperation(DARTWIC::API::OperationDefinition{
        .id = "echo",
        .name = "Echo",
        .description = "Return the supplied message.",
        .arguments = {{.name = "message", .type = "string",
                       .description = "Text to echo.", .required = true}},
        .handler = [](const nlohmann::json& payload) {
            return nlohmann::json{{"echo", payload}};
        }
    });
}

The handler runs when the operation is called, not when it is registered. This is a plugin extension operation carried by TEMPEST. See the operation registration reference for descriptors and access options.

Tasks

A task type defines behavior that can be reused by several configured tasks. Registering it does not create or start those instances. Each instance supplies its own arguments and runtime state; its structure selects how it executes, such as periodic work or a long-running worker.

Use on_configure to prepare bindings and buffers, on_start to begin a run, on_task for its work, and on_end / cleanup to finish and release state. on_missed handles skipped scheduled releases. The supplied TaskRuntime exposes the instance’s name, arguments, elapsed time, stop request, and retained context.

For a small periodic task, add this inside onPluginLoaded(). Each instance publishes its elapsed seconds on its own channel:

using namespace DARTWIC::API;
TaskTypeDefinition elapsed;
elapsed.metadata.structure = TaskStructure::Periodic;
elapsed.on_task = [api = dartwic](const TaskTypeDefinition&,
                                TaskRuntime& task, double seconds) {
    api->upsertChannelField(task.getTaskName() + "_elapsed",
                            ChannelField::VALUE, seconds);
};
dartwic->registerTaskType("elapsed", "Elapsed time", std::move(elapsed));

Configure a task using type example_device_plugin.elapsed, choose its rate, then start it. This simple example publishes by name; the driver guide shows preparing fixed handles for repeated device I/O.

Plugin loops

A plugin loop instead supplies plugin-level work without requiring a task instance for every device. Modules and Tasks explains lifecycle; Building a Hardware Driver shows task registration, module binding, and discovery loops together.

A plugin-wide loop can instead be registered directly. This one publishes a constant health marker once per second:

dartwic->registerLoop("heartbeat", "Plugin heartbeat", {
    .on_loop = [api = dartwic]() {
        api->upsertChannelField("example_heartbeat", DARTWIC::API::ChannelField::VALUE, 1.0);
    },
    .target_frequency_hz = 1.0,
});

Each accepted write refreshes the channel timestamp even when its value stays 1. The marker describes this loop’s activity, not device health.

DCode functions

A DCode function lets a script call your C++ implementation directly, for example a calculation or a device-specific command helper. Register its input and result schema alongside its handler. Scripts call the qualified name; Modules and Native Functions shows the example plugin’s calls from DCode.

For example, add a numeric helper inside onPluginLoaded():

dartwic->registerDCodeFunction(
    "double_value", "Double value",
    [](const nlohmann::json& payload) {
        return nlohmann::json(payload.at("value").get<double>() * 2.0);
    },
    "Multiply a number by two.",
    {{"value", "number", "Input number.", true}},
    {{"result", "number", "Doubled number.", true}}
);

With that plugin loaded, an executable DCode body can call it:

local doubled = example_device_plugin.double_value({value: 12.5})
|demo_doubled| = doubled

The declared result has one field, so the handler returns a scalar here.

The call is synchronous: the script waits for your handler, including its I/O, locks, and calculations. Ordinary JSON and optional prepared handlers share one schema. Prepared argument views and result writers last only for that call; see the registration reference for types, mutation, and fixed-shape rules.

Module instances

A module owns a configured connection or service shared by tasks. Register its type in onPluginLoaded(), then implement createModule to return a BaseModule for that local type ID. The factory receives the instance’s merged configuration and SDK pointer.

The starter’s simulated device illustrates both sides. Inside onPluginLoaded(), advertise the type:

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

Return its instance from the factory when that local type is requested:

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

ExampleDeviceModule is the starter’s module implementation; your driver would supply its own class.

Keep connection settings and device lifetime on the module, and repeated work on its tasks or loop. The driver guide covers this implementation without requiring every plugin to follow a device pattern.

Telemetry topics

A telemetry topic sends custom updates independently of operation responses. Register its description once, then publish from your runtime work. Here a plugin loop supplies the updates:

dartwic->registerTelemetry(DARTWIC::API::TelemetryDefinition{
    .id = "status", .name = "Plugin status",
    .description = "Periodic plugin readiness.",
    .delivery = "Published once per second while the loop runs."
});
dartwic->registerLoop("status_updates", "Status updates", {
    .on_loop = [api = dartwic]() {
        api->publishTelemetry("status", {{"ready", true}});
    },
    .target_frequency_hz = 1.0,
});

Clients handle the qualified topic example_device_plugin.status. Delivery is best effort; publishing does not create recorded channel history. See telemetry registration and publication.

Peer types

A transport registration adds a link option for the standard DARTWIC peer. This uses the starter’s ExampleTransport, whose implementation supplies the actual connection, message delivery, and diagnostics:

#include "example_transport.h"
#include <dartwic/EngineProtocol.h>

// Inside onPluginLoaded():
dartwic->registerPeerTransport({
    .id = "example_flight_link", .name = "Example Flight Link",
    .default_config = {
        {"node_name", "external-program"},
        {"receive_endpoint", "tcp://127.0.0.1:17600"},
        {"send_endpoint", "tcp://127.0.0.1:17601"}
    },
    .protocol_id = "tempest.engine",
    .protocol_version = DARTWIC::EngineContract::version,
    .engine_protocol = true,
    .create = [](const nlohmann::json& cfg) {
        return std::make_shared<Example::ExampleTransport>(cfg);
    }
});

Keep includes at file scope. Registration makes the connection type available; Network Map configures a connection using it. Building a Custom Peer explains custom transports and protocol bridges. Use the transport registration reference for the full definition.

Engine APIs

The Engine Plugin SDK exposes normal Engine services through SDK_API. In your starter, engine/include/sdk/sdk_api.h declares that interface, with supporting types and helpers alongside it. The generated reference explains their arguments and behavior.

Your registered handlers, tasks, and modules use these APIs to do their work:

NeedAPI area
Read/publish signals or command an outputChannels and Channel Authority
Report conditions or diagnostic outputEvents and Logs
Call operations or publish custom updatesOperations
Read settings, private files, or portable assetsStorage and Settings
Offer an operator workflowInterface UI and Notifications

For example, a simulated publisher can update a channel and write a log through the same SDK pointer used for registration:

using DARTWIC::API::ChannelField;
dartwic->upsertChannelField("demo_temperature", ChannelField::VALUE, 23.5);
dartwic->upsertChannelField("demo_temperature", ChannelField::UNITS, std::string("degC"));
const bool accepted = dartwic->log("Setup", "Temperature source ready");

accepted reports whether ARGUS accepted the log line for asynchronous writing. log appends a newline; use writeLog for raw text or an explicit output channel and level.

Channel writes still obey authority. Ordinary updates use existing authority without acquiring it. Prepare fixed handles and device resources during configuration; explicitly acquire publishing authority on each start before ordinary sample writes. The SDK’s commandChannel, setChannel, overrideChannel, and freeChannel operations accept names or retained fixed handles, with or without a value. Channels and Control explains the native DAQ lifecycle and atomic authority/write behavior.

Accepted writes can run dependent calculations inline. Recording and telemetry follow asynchronously; device delivery belongs to your driver. Report feedback and failures separately from command acceptance. For fixed-channel preparation, use Native C++ Execution.

Building and Installation

Use Packaging and Installation for tools, build commands, output layout, local installation, and releases.

After installation or an update, restart the selected Engine and check that the plugin loaded. Then configure and exercise the contributions you added. If loading fails, check the Engine log for ABI, dependency, or entry-point errors; Plugins explains installed and loaded states.

For the echo registration above, an already connected Python client can check dispatch:

response = client.operation("example_device_plugin.echo", {"message": "hello"})
response.raise_for_error()
print(response.payload["echo"]["message"])  # hello

This checks the registered handler. Test each task, device, or UI contribution separately before treating the whole plugin as verified.

Events and Logs

Use the DCode-named SDK helpers message, warning, error, and abort to report operator events. hold, releaseHold, and the typed promptNumerical, promptYesNo, promptString, and promptOptions helpers cover operator workflows; stop_event_firing ends a firing occurrence. SDK holds and prompts block their calling C++ thread, so use a worker or sequence that can wait. Events and Logs explains argument order, graphs, results, and completion. The event reference lists complete signatures. Use log, log_warning, and log_error for text that does not need an event lifecycle.

Reference