26 KiB
title
| title |
|---|
| Driver Development |
Driver Development
Drivers are the southbound I/O layer of IoT DC3. They bring heterogeneous protocol devices — Modbus, OPC UA, MQTT, S7,
BACnet, and more — into the platform's data and command planes through a single interface. This page walks through
deriving a new protocol driver from the dc3-driver-virtual template, then covers the driver lifecycle, read/write
scheduling, and the one routing identifier you must not change after going to production. By the end you'll have a
driver that registers, collects data, and accepts commands.
You are here: you want to onboard devices speaking a protocol that no existing driver supports. If you only want to use an existing driver, start with the Operation Manual and Quick Start. Next, see the Command Plane to understand how read/write commands flow back to devices.
Unless otherwise noted, run commands from the iot-dc3 repository root.
What a driver is: a Spring Boot service that aggregates 7 SPIs
A driver is a standalone Spring Boot service (dc3-driver-<protocol>). It does not talk directly to the management
center or the data center. Instead it inherits the dc3-common-driver SDK, which handles registration, scheduling,
RabbitMQ messaging, gRPC calls, and tenant context — so you only implement the protocol logic.
That logic is exposed through a single entry interface: DriverCustomService. It declares no methods of its own; it
aggregates 7 single-responsibility SPI sub-interfaces. Implementing this one interface puts all 7 concerns in your
hands:
| SPI sub-interface | The question you must answer |
|---|---|
DriverLifecycle |
What to initialize when the process starts (initial())? What to do on each custom cycle (schedule())? |
DriverProtocol |
How to read a point from the device (read(...))? How to write a point (write(...))? |
DriverCommand |
How to run the custom commands defined in the template (execute(...))? |
DriverMetadataListener |
How to refresh local caches when device/point metadata changes (event(...))? |
DriverHealth |
Is the driver as a whole ONLINE / OFFLINE / FAULT / MAINTAIN? |
DeviceHealth |
How to determine the online state of a single device? |
DriverValidator |
Is the driver/point configuration valid (validate*)? Can it generate simulated values? |
Source entry point: dc3-common/dc3-common-driver/.../service/DriverCustomService.java (a single extends line
stitches the 7 interfaces together). The dc3-driver-virtual template ships runnable example implementations for all 7,
which makes it the best starting point for a new driver.
::: tip Terminology alignment
Attribute comes from dc3.driver.*-attribute in the driver's application.yml and defines "which configuration
items this driver has". Config is the concrete value a given device fills in for those attributes, stored in the
management center. The driver registers attributes at startup and retrieves a device's config values at runtime through
Map<String, AttributeBO>.
:::
Lifecycle: register (with retry) → initial → schedule
After the driver process starts, DriverInitRunner (an ApplicationRunner) runs a fixed bootstrap sequence: it first
registers itself and all attribute definitions with the management center, then calls your initial() for one-time
setup once registration succeeds, and finally lets the SDK wire up scheduled tasks (read scheduling, custom scheduling,
device health checks).
Registration goes over gRPC, and the management center may not be ready when the driver starts (rolling restarts, pod
rescheduling). So registration is not one-shot: DriverInitRunner.registerWithRetry() retries with capped exponential
backoff — starting at 2 seconds, doubling each time, capped at 30 seconds, up to 30 attempts. Only if all 30 fail does
it throw and exit. Without this retry, a brief blip in the management center would drag the driver into
CrashLoopBackOff.
stateDiagram-v2
[*] --> STARTING : process starts DriverInitRunner.run()
STARTING --> REGISTERING : registerWithRetry()
REGISTERING --> REGISTERING : registration failed<br/>exponential backoff 2s→30s, up to 30 attempts
REGISTERING --> INITIAL : registration succeeded<br/>driverCustomService.initial()
REGISTERING --> [*] : still failing after 30 attempts, throw and exit
INITIAL --> SCHEDULING : driverScheduleService.initialize()
SCHEDULING --> RUNNING : read/custom/health tasks wired up
RUNNING --> RUNNING : periodic read() + periodic status report
Source: dc3-common/dc3-common-driver/.../init/DriverInitRunner.java (REGISTER_MAX_ATTEMPTS=30,
REGISTER_INITIAL_BACKOFF=2s, REGISTER_MAX_BACKOFF=30s). initial() runs only once at startup — use it to build
connection pools and subscriptions. schedule() fires on the cron defined in dc3.driver.schedule.custom.
From template to new driver: four steps
The work for a new driver concentrates in four places: copy the template, edit pom.xml, edit application.yml, and
implement DriverCustomService. The diagram shows the overall path, expanded step by step afterward.
flowchart LR
A["copy template<br/>dc3-driver-virtual → dc3-driver-knx"] --> B["edit pom.xml<br/>inherit dc3-driver parent module<br/>add protocol library dependency"]
B --> C["edit application.yml<br/>dc3.driver code/name<br/>driver/point-attribute"]
C --> D["implement DriverCustomService<br/>read() / write() / schedule()..."]
D --> E["package + run<br/>auto-register with management center"]
Step 1: Copy the template and rename
Name the driver module dc3-driver-<protocol>, with the protocol name in kebab-case:
cp -r dc3-driver/dc3-driver-virtual dc3-driver/dc3-driver-knx
Then rename the Java package, the application class, and the custom service implementation class. The two key classes in the template are:
| Class | Description |
|---|---|
VirtualDriverApplication |
Spring Boot application class |
VirtualDriverCustomServiceImpl |
Protocol logic entry point (implements DriverCustomService) |
A new driver should use protocol-specific names — KnxDriverApplication, KnxDriverCustomServiceImpl — to avoid
duplicate class names across drivers. Put the application class and the implementation class under the same parent
package so component scanning picks up the @Service-annotated DriverCustomService implementation:
@SpringBootApplication
public class KnxDriverApplication {
public static void main(String[] args) {
SpringApplication.run(KnxDriverApplication.class, args);
}
}
Step 2: Wire into the parent POM
Register the new module in the <modules> of dc3-driver/pom.xml:
<modules>
<module>dc3-driver-knx</module>
<!-- existing modules -->
</modules>
The new module's own pom.xml usually just inherits the driver parent module and adds the protocol library:
<parent>
<groupId>io.github.pnoker</groupId>
<artifactId>dc3-driver</artifactId>
<version>2026.5.22</version>
</parent>
<artifactId>dc3-driver-knx</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- Protocol library, e.g. calimero-core (KNX). Heavy protocol dependencies belong here only, not in dc3-common-driver -->
</dependencies>
The dc3-driver parent module already pulls in dc3-common-driver (the SDK) and the Spring Boot Maven Plugin, so you
don't need to declare them again.
Step 3: Configure application.yml
dc3.driver is the driver's most important user-visible configuration. The SDK reads it at startup and registers it
with the management center, which uses it to render the device and point configuration forms. The example below follows
the real structure of dc3-driver-virtual, with KNX semantics in place of virtual's values:
dc3:
driver:
tenant: default
name: KNX Driver
code: KnxDriver # stable routing identifier, see constraints below
type: DRIVER_CLIENT
remark: @project.description@
schedule:
read: # read scheduling: periodically collect point values
enabled: true
cron: '0/30 * * * * ?' # one round every 30 seconds
custom: # custom scheduling: driver schedule() callback
enabled: true
cron: '0/5 * * * * ?'
health:
device: # device health reporting
enabled: true
cron: '0/15 * * * * ?'
timeout: 45 # device status lease TTL (seconds)
timeout-unit: SECONDS
driver-attribute: # driver-level attributes: filled once per device instance
- attribute-name: Host
attribute-code: host
attribute-type-flag: STRING
default-value: localhost
remark: KNX/IP gateway host
- attribute-name: Port
attribute-code: port
attribute-type-flag: INT
default-value: 3671
remark: KNX/IP gateway port
point-attribute: # point-level attributes: filled once per point
- attribute-name: Group Address
attribute-code: groupAddress
attribute-type-flag: STRING
default-value: 1/0/1
remark: KNX group address
spring:
application:
name: @project.artifactId@
profiles:
active:
- ${NODE_ENV:dev}
logging:
file:
name: dc3/logs/driver/knx/${spring.application.name}.log
What the attribute fields mean (the prose above builds the mental model; this table is a quick reference):
| Field | Description |
|---|---|
attribute-name |
UI display name; driver metadata is conventionally in English |
attribute-code |
The stable key the protocol implementation reads, e.g. host, port, objectType |
attribute-type-flag |
Attribute type; AttributeTypeEnum has 8 values: STRING / BYTE / SHORT / INT / LONG / FLOAT / DOUBLE / BOOLEAN |
default-value |
Default value |
remark |
Description text; English recommended |
::: warning The toggle field is named enabled
The scheduling toggle field is named enabled. DriverScheduleServiceImpl reads getRead().getEnabled() /
getCustom().getEnabled() / device.getEnabled(), bound to the private Boolean enabled inside DriverProperties.
Spring's relaxed binding will not map enable to enabled — they are different property names. The
dc3-driver-virtual template writes enable, which actually has no effect. For a new driver, use enabled. Device
health's enabled defaults to false and must be explicitly set to true to turn it on.
:::
The attribute registration chain: dc3.driver in application.yml → SDK parses it into a RegisterBO → submitted to
the management center over gRPC. The diagram shows the entity relationships of this flow:
flowchart LR
YML["application.yml<br/>dc3.driver.*-attribute"] --> Props["DriverProperties<br/>(SDK binding)"]
Props --> Reg["RegisterBO<br/>code/name/service/tenant + attribute definitions"]
Reg -->|"gRPC"| MGR["management center dc3-center-manager"]
MGR --> DB[("dc3_driver_attribute<br/>dc3_point_attribute")]
MGR --> UI["management-side rendering<br/>device/point config forms"]
Step 4: Implement DriverCustomService
The core protocol logic lives in the DriverCustomService implementation. read(...) returns a single
ReadPointValue, and write(...) returns a Boolean. That is the entire protocol contract's outward commitment (
source DriverProtocol.java):
@Slf4j
@Service
public class KnxDriverCustomServiceImpl implements DriverCustomService {
@Resource
private DriverMetadata driverMetadata;
@Resource
private DriverSenderService driverSenderService;
@Override
public void initial() {
// One-time initialization: set up the protocol stack, connection pool, subscriptions
}
@Override
public void schedule() {
// Custom periodic task, e.g. periodically report device status (with TTL)
driverMetadata.getDeviceIds().forEach(deviceId ->
driverSenderService.deviceStatusSender(
deviceId, EntityStatusEnum.ONLINE, 45, TimeUnit.SECONDS));
}
@Override
public void event(MetadataEventDTO metadataEvent) {
// React to device/point metadata changes (ADD/UPDATE/DELETE), refreshing local cache or subscriptions
}
@Override
public ReadPointValue read(Map<String, AttributeBO> driverConfig,
Map<String, AttributeBO> pointConfig,
DeviceBO device,
PointBO point) {
String host = driverConfig.get("host").getValue(String.class);
Integer port = driverConfig.get("port").getValue(Integer.class);
String groupAddress = pointConfig.get("groupAddress").getValue(String.class);
// Perform the protocol read, returning the raw string value (example value "0")
return new ReadPointValue(device, point, "0");
}
@Override
public Boolean write(Map<String, AttributeBO> driverConfig,
Map<String, AttributeBO> pointConfig,
DeviceBO device,
PointBO point,
WritePointValue writePointValue) {
// Perform the protocol write; return true only when the device confirms the write succeeded
return true;
}
}
::: danger Do not swallow exceptions on read/write failure
Throwing an exception from read() / write() is the SDK's agreed failure signal — the SDK logs it and acks or nacks
the command on RabbitMQ. When a write command fails, the result does not echo back the written value (
responseValue=null), to avoid a "false success". And a single point's read failure should not bring down the whole
collection round.
:::
Read/write scheduling: how data goes out, how commands come in
A driver has two data flows running in opposite directions. The SDK orchestrates both; you only fill in the protocol implementation.
Read (outbound): Quartz's DriverReadScheduleJob fires on the cron in dc3.driver.schedule.read, iterates this
driver's devices from the DriverMetadata cache, submits a read task per device (thread pool), calls your read() to
get a ReadPointValue, and the SDK then sends it to the data center over RabbitMQ. You do not write the RabbitMQ or
gRPC plumbing yourself.
Write (inbound): the data center dispatches read/write commands to this driver's command queue over RabbitMQ.
PointCommandReceiver deduplicates them, locks per device, then calls your read() or write() in turn and sends the
result back to the data center.
sequenceDiagram
participant Quartz as Quartz read scheduler
participant Meta as DriverMetadata cache
participant Impl as DriverCustomService.read()
participant MQ as RabbitMQ
participant Data as data center dc3-center-data
Note over Quartz,Data: Outbound: periodic collection
Quartz->>Meta: iterate this driver's devices
Meta-->>Quartz: deviceIds
Quartz->>Impl: read(driverConfig, pointConfig, device, point)
Impl-->>Quartz: ReadPointValue
Quartz->>MQ: pointValueSender(PointValue)
MQ->>Data: persist to dc3_point_value
Note over Data,Impl: Inbound: write command
Data->>MQ: dispatch dc3.e.point_command
MQ->>Impl: write(..., WritePointValue)
Impl-->>MQ: Boolean(true/false)
MQ->>Data: pointCommandResultSender(result)
Inbound write handling on the driver side is not a bare write() call — it is a pipeline with validation,
deduplication, and locking. The diagram below expands PointCommandReceiver's pipeline, including error paths:
flowchart TB
In["command received<br/>dc3.q.point_command.{service}"] --> Exp{"expired?<br/>now past expireAt"}
Exp -->|yes| Expired["mark EXPIRED, ack"]
Exp -->|no| Dedup{"dedup hit?<br/>Caffeine 5 minutes"}
Dedup -->|hit| Dup["mark DUPLICATE, ack"]
Dedup -->|miss| Lock["lock per device<br/>DeviceLockManager (ReentrantLock)"]
Lock --> Exec["execute read() / write()"]
Exec -->|success| OK["return result SUCCESS/FAILED"]
Exec -->|exception, first delivery| Nack["nack requeue + release dedup"]
Exec -->|exception, redelivery| Fail["return result FAILED + ack<br/>avoid infinite loop"]
The send side goes through DriverSenderService (source DriverSenderService.java). Common methods:
| Method | Purpose |
|---|---|
pointValueSender(PointValue) / pointValueSender(List<PointValue>) |
Send a single or batched set of point values |
deviceStatusSender(deviceId, status) |
Report device status (default TTL) |
deviceStatusSender(deviceId, status, timeout, unit) |
Report device status with TTL |
driverAlarmSender(String) |
Report a driver-level alarm |
deviceAlarmSender(deviceId, String) |
Report a device-level alarm |
eventReportSender(EventReportDTO) |
Report a device event |
pointCommandResultSender(...) / commandResultSender(...) |
Acknowledge command results |
The status values are defined in EntityStatusEnum: ONLINE(0) / OFFLINE(1) / MAINTAIN(2) / FAULT(3).
::: warning The device status TTL must be greater than the read cycle
Device status is reported as a "lease": if it is not renewed before expiry, the device is judged offline. The TTL must
be greater than the status-report or read cycle, otherwise the device will be judged offline between two heartbeats
and flap repeatedly. For example, with a read cron of 0/30 * * * * ? (every 30 seconds), the TTL should be ≥ 25
seconds. The template's default device health timeout: 45 seconds leaves plenty of margin.
:::
Naming and routing: the identifier you must not change
Driver routing involves three identifiers. Telling them apart avoids a trap you cannot undo after going to production:
| Identifier | Source | Purpose |
|---|---|---|
dc3.driver.code |
application.yml |
Unique driver-type code; the management center uses it to identify the driver type |
dc3.driver.service |
Auto-derived or explicitly overridden | Driver instance routing identifier, used for the RabbitMQ command queue and routing key |
spring.application.name |
Maven artifactId | Log file name, Actuator metadata, etc. |
::: danger dc3.driver.code is a stable identifier; changing it requires migration
Once in production, dc3.driver.code must not be changed casually. It is registered with the management center as the
driverCode and binds all metadata of that driver type — changing it amounts to swapping in a new driver type, all
onboarded devices will be lost, and a data migration plan is mandatory. (The RabbitMQ command queue and routing key are
built from dc3.driver.service, not code — see the table above.)
:::
Build, run, and smoke test
To run locally, first load the environment variables, so the local Java process points at the dependency ports Compose publishes to localhost:
source dc3/env/dev.env.sh
Build the new driver and its dependencies, then run:
::: code-group
mvn -s .mvn/settings.xml clean package -pl dc3-driver/dc3-driver-knx -am
java -jar dc3-driver/dc3-driver-knx/target/dc3-driver-knx.jar
:::
In development the driver auto-registers with the management center. Check the driver logs and confirm an event like
Driver register succeeded appears — that means registration worked. (During retries it prints
Driver register failed on attempt n/30, retrying....)
Run an end-to-end smoke test along the golden path (HTTP paths and fields come from the gateway contract; example values are marked as examples):
- On the management side, create the driver, profile, point, and device, and fill in config values for the driver
attributes
host/portand the point attributegroupAddress. - Wait for one read cycle (30 seconds by default).
- Fetch the latest point value to confirm that
read()'s collection has been persisted:
# Example: deviceId/pointId are example values
curl -X POST http://localhost:8000/api/v3/data/point_value/latest \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: <token>' \
-H 'Content-Type: application/json' \
-d '{"deviceId": 1, "pointId": 1, "page": {"current": 1, "size": 10}}'
- Dispatch a write command to a writable point to confirm
write()is invoked and acknowledged:
curl -X POST http://localhost:8000/api/v3/data/point_command/write \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: <token>' \
-H 'Content-Type: application/json' \
-d '{"deviceId": 1, "pointId": 1, "value": "42"}'
The endpoint returns a commandId. Use it to query the command history and check the execution status (
PointCommandHistoryVO's status takes SUCCESS/FAILED etc.; on a successful write, responseValue echoes the
written value):
curl -X GET 'http://localhost:8000/api/v3/data/point_command_history/get_by_command_id?commandId=<commandId>' \
-H 'X-Auth-Tenant: default' \
-H 'X-Auth-Login: dc3' \
-H 'X-Auth-Token: <token>'
For the full command lifecycle and acknowledgment semantics, see the Command Plane.
::: info Where the auth headers come from
All protected endpoints require X-Auth-Tenant / X-Auth-Login / X-Auth-Token. Get the token by fetching the salt
via POST /api/v3/auth/token/salt and exchanging it via POST /api/v3/auth/token/generate (valid for 12 hours). See
the API Documentation for details.
:::
FAQ
| Problem | Root cause and handling |
|---|---|
| Driver code conflict | Duplicate dc3.driver.code — keep it globally unique and stable; do not change the code of an already-deployed driver |
DriverCustomService not loaded |
The implementation class lacks @Service, or sits outside the application class's component-scan scope |
| Registration keeps retrying without success | Management center not ready or gRPC unreachable — check the Driver register failed on attempt n/30 logs, verify CENTER_MANAGER_HOST and the management center's health |
read returns empty or throws |
Do not swallow exceptions; let the logs surface the protocol error. A single-point failure should not bring down the whole round |
| Devices frequently go offline (flap) | The status TTL is smaller than the read/report cycle — increase the TTL or shorten the schedule cycle |
| Reads happen but the data page has no value | Check RabbitMQ connectivity, data center logs, and tenant context |
| Metadata changes have no effect | Update the local protocol client, subscriptions, or cache inside event(...) |
| Heavy protocol dependency | Put it only in the specific driver module's pom.xml, not in dc3-common-driver |
Further reading
- Command Plane — how read/write commands are dispatched, deduplicated, locked, and
acknowledged, and how they connect to this page's
read()/write() - Module Map — the full picture of the 28 driver modules and where the
dc3-common-driverSDK sits in the dependency tree - Domain Model — the fields and boundaries of Profile / Point / Device and the three layers Param/Attribute/Config
- API Documentation — the auth flow, gateway contract, and OpenAPI
- Troubleshooting — issues with startup dependencies, ports, and environment variables