Files
iot-dc3/docs/development/driver-authoring.md
T

15 KiB
Raw Blame History

Authoring a New Driver

This guide walks you through creating a new device driver from scratch using dc3-driver-virtual as a template. Drivers connect the DC3 platform to physical devices via a specific protocol (Modbus, OPC, MQTT, S7, …) and are the southbound I/O layer of the system.

If you only want to use an existing driver, see 快速开始 instead.

Unless stated otherwise, run commands from the repository root.

What a driver does

A driver is a Spring Boot service that:

  1. Registers itself with dc3-center-manager on startup (driver name, code, attribute schema, point attribute schema). Manager uses this to render the device-config UI.
  2. Reads point values from physical devices on a configurable cron and pushes them to dc3-center-data via RabbitMQ.
  3. Writes values to devices when commands arrive from dc3-center-data over RabbitMQ.
  4. Reports device status (online / offline / fault / maintain) on a configurable cron.
  5. Reacts to metadata change events (device / point added, removed, updated) so it can adjust its in-memory state without restarting.

All five behaviours are exposed through one SPI: DriverCustomService. The SDK (dc3-common-driver) takes care of registration, RabbitMQ wiring, scheduling, and gRPC plumbing — your driver only implements the protocol-specific parts.

Prerequisites

  • Java 21 + Maven 3.9+
  • A running dc3-center-manager, dc3-center-data, dc3-center-auth, RabbitMQ, and Postgres. Easiest: make dev-all. See 快速开始.
  • Familiarity with whichever device protocol your driver implements.

Step 1 — Create the module

Pick a short, descriptive name in kebab-case: dc3-driver-<protocol> (e.g. dc3-driver-bacnet).

cd /path/to/iot-dc3
cp -r dc3-driver/dc3-driver-virtual dc3-driver/dc3-driver-bacnet
cd dc3-driver/dc3-driver-bacnet

Then rename the Java package and main class to match. The dc3-driver-virtual template has a single service/impl/VirtualDriverCustomServiceImpl.java and a VirtualDriverApplication.java. Rename both to your driver, and keep the custom service implementation name protocol-specific (for example, BacnetDriverCustomServiceImpl) so static analysis tools can scan every driver module without filtering duplicate fully qualified class names.

Step 2 — Wire into the parent POM

Add your module to dc3-driver/pom.xml <modules>:

<modules>
  <module>dc3-driver-bacnet</module>
  <!-- existing entries -->
</modules>

Your module's own pom.xml only needs:

<parent>
  <groupId>io.github.pnoker</groupId>
  <artifactId>dc3-driver</artifactId>
  <version>2026.5.18</version>
</parent>
<artifactId>dc3-driver-bacnet</artifactId>
<packaging>jar</packaging>

<dependencies>
  <!-- protocol-specific libraries go here, e.g. bacnet4j -->
</dependencies>

The parent already pulls in dc3-common-driver and the Spring Boot Maven plugin.

Step 3 — Application class

Drop a single @SpringBootApplication-annotated entry point. Same package as your protocol-specific DriverCustomService implementation so default component scanning picks both up:

@SpringBootApplication
public class BacnetDriverApplication {
    public static void main(String[] args) {
        SpringApplication.run(BacnetDriverApplication.class, args);
    }
}

Step 4 — application.yml

This is the most important file: the keys under dc3.driver: are read by the SDK at startup and pushed to Manager during registration. They define the schema of attributes that operators will fill in when configuring a device or point in the UI.

dc3:
  driver:
    tenant: default
    name: BACnet驱动            # human-readable name
    code: BacnetDriver          # unique routing key — see "Naming" below
    type: DRIVER_CLIENT
    remark: @project.description@

    schedule:
      read:                     # periodic read of every point
        enable: true
        cron: '0/30 * * * * ?'  # every 30s
      custom:                   # your DriverCustomService#schedule() callback
        enable: true
        cron: '0/5 * * * * ?'

    driver-attribute:           # per-device config (one row per device)
      - attribute-name: 主机
        attribute-code: host
        attribute-type-flag: STRING
        default-value: localhost
        remark: BACnet host
      - attribute-name: 端口
        attribute-code: port
        attribute-type-flag: INT
        default-value: 47808
        remark: BACnet port

    point-attribute:            # per-point config (one row per point on the device)
      - attribute-name: 对象类型
        attribute-code: objectType
        attribute-type-flag: STRING
        default-value: analog-input
        remark: BACnet object type
      - attribute-name: 实例号
        attribute-code: instance
        attribute-type-flag: INT
        default-value: 0
        remark: Object instance number

spring:
  application:
    name: @project.artifactId@
  profiles:
    active:
      - ${NODE_ENV:dev}

logging:
  file:
    name: dc3/logs/driver/bacnet/${spring.application.name}.log

You'll also typically copy application-dev.yml / -pre.yml / -pro.yml from dc3-driver-virtual and adjust the schedule cron. Never hardcode localhost — everything goes through ${ENV:default} placeholders. See dc3/env/dev.env.sh for the env-var contract.

Attribute types

attribute-type-flag is one of: STRING, INT, LONG, DOUBLE, FLOAT, BOOLEAN, BYTE, SHORT. Pick what makes sense; the value reaches your read / write methods as an AttributeBO whose typed accessor (.getValue(Type)) handles parsing.

Step 5 — Implement DriverCustomService

Five methods. Drop your protocol logic in:

@Slf4j
@Service
public class BacnetDriverCustomServiceImpl implements DriverCustomService {

    @Resource private DriverMetadata driverMetadata;
    @Resource private DriverSenderService driverSenderService;

    /** Called once at startup. Open connection pools, init protocol stacks, etc. */
    @Override
    public void initial() { /* ... */ }

    /**
     * Called on the cron defined by `dc3.driver.schedule.custom.cron`. Use it for anything
     * that doesn't fit `read` (e.g. heartbeat, status polling, reconnect retries).
     * Typically you push status updates here:
     */
    @Override
    public void schedule() {
        driverMetadata.getDeviceIds().forEach(id ->
            driverSenderService.deviceStatusSender(id, DeviceStatusEnum.ONLINE, 25, TimeUnit.SECONDS));
    }

    /**
     * Manager fires this whenever a device or point is added/updated/deleted. Use it to
     * drop cached protocol clients, rebuild subscriptions, etc.
     */
    @Override
    public void event(MetadataEventDTO metadataEvent) { /* ... */ }

    /**
     * Read a single point value. Called once per (device, point) on the
     * `dc3.driver.schedule.read.cron`. driverConfig holds the device-level attributes the
     * operator filled in; pointConfig holds the per-point attributes.
     *
     * Return ReadPointValue with the raw string; the framework will run any configured
     * scaling / unit conversion before persisting.
     */
    @Override
    public ReadPointValue read(Map<String, AttributeBO> driverConfig,
                       Map<String, AttributeBO> pointConfig,
                       DeviceBO device, PointBO point) {
        String host = driverConfig.get("host").getValue(String.class);
        int port    = driverConfig.get("port").getValue(Integer.class);
        // ... protocol-specific read, return new ReadPointValue(device, point, "<raw value>")
    }

    /**
     * Write a value to a point. Called when an external command arrives over RabbitMQ.
     * Return false (or throw) if the protocol does not support writes for this point.
     */
    @Override
    public Boolean write(Map<String, AttributeBO> driverConfig,
                         Map<String, AttributeBO> pointConfig,
                         DeviceBO device, PointBO point, WritePointValue writePointValue) {
        // ... protocol-specific write
        return true;
    }
}

Step 6 — Push values & status from the SDK

You don't need to write any RabbitMQ or gRPC plumbing. Use the injected DriverSenderService:

Method Purpose
pointValueSender(PointValue) / pointValueSender(List<PointValue>) Push a single (or batch) of values to dc3-center-data. Use this for push-style drivers (MQTT, OPC subscriptions); polling drivers usually just return ReadPointValue from read and let the SDK send it.
deviceStatusSender(deviceId, status) / (deviceId, status, ttl, unit) Report a device as ONLINE / OFFLINE / FAULT / MAINTAIN. The TTL drives auto-OFFLINE on silence (default ~25 s if you use the no-TTL overload).
driverEventSender(DriverEventDTO) / deviceEventSender(DeviceEventDTO) Emit structured driver- or device-scoped events that show up in the dashboard alert feed.
driverAlarmSender(String) / deviceAlarmSender(deviceId, String) Quick path for alarm-style events with just a human-readable reason (e.g. "OPC UA session dropped").

Status TTLs: pick a number larger than your schedule cron interval. Otherwise the device flips to OFFLINE between two heartbeats. For example, with cron: '0/5 * * * * ?' (every 5 s), 25 SECONDS gives a 5× safety margin.

Step 7 — Driver naming and routing

Three identifiers participate in driver routing:

  • dc3.driver.code (from application.yml) — the unique driver-type key Manager uses to reject duplicate registrations. Validated by a strict regex: ^[A-Za-z0-9][...]{1,31}$.
  • dc3.driver.service (auto-derived; can be overridden) — the per-instance routing identifier used as the suffix on RabbitMQ command queues (dc3.q.command.driver.<service>) and routing keys (dc3.r.command.driver.<service>). See RabbitConstant.
  • spring.application.name (@project.artifactId@) — controls log filenames and Actuator metadata. Has no routing implication.

Pick dc3.driver.code once and never change it. Renaming it after the driver has any devices configured against it requires a migration of dc3_driver, dc3_driver_attribute, and the RabbitMQ bindings.

Step 8 — Build and run

# Build everything (runs protobuf-maven-plugin → builds the SDK → builds your driver)
source dc3/env/dev.env.sh
mvn -s .mvn/settings.xml clean package -pl dc3-driver/dc3-driver-bacnet -am

# Or via the project Makefile
make package

Run it standalone (with center services already up via make dev-db dev-optional):

java -jar dc3-driver/dc3-driver-bacnet/target/dc3-driver-bacnet.jar

In dev mode the service registers itself with dc3-center-manager automatically — watch the manager logs for Driver registered: BacnetDriver. Once registered, it shows up under the Driver menu in the UI; you can then create a Profile, attach it to Devices, and bind Points.

Step 9 — Smoke-test the loop

  1. Create a device via the UI → fill in host / port (your driver-attribute schema).
  2. Bind a point → fill in objectType / instance (your point-attribute schema).
  3. Wait one dc3.driver.schedule.read.cron cycle.
  4. Inspect a value either in the Live values dashboard, in the database (select * from dc3_point_value order by create_time desc limit 5;), or with curl against dc3-gateway (/api/v3/data/point_value/...).
  5. Trigger a write via the UI's Send command button — your write method should fire.

Common gotchas

  • dc3.driver.code collides with another driver. Manager rejects the registration. Pick a unique code; convention is PascalCase ending in Driver.
  • DriverCustomService implementation not picked up. The class needs @Service and must live in a package scanned from your @SpringBootApplication class — i.e. same package or below.
  • read returns null / throws. The framework logs and skips the point for that cycle; it does not fail the whole driver. Don't catch and return null silently — let the exception surface so you see it in logs.
  • Status flips OFFLINE every cycle. Your TTL is shorter than the schedule interval. Bump the TTL or shorten the schedule.
  • Points read but never appear in the dashboard. The driver is sending values to RabbitMQ, but dc3-center-data isn't running, or RabbitMQ credentials are wrong. Check RABBITMQ_HOST env vars and the data-center logs.
  • Hot-reload of metadata. Don't cache DeviceBO / PointBO references across calls without listening on event(...). Manager mutates these on the fly.
  • Heavy protocol library. If your driver pulls in a fat protocol stack (e.g. bacnet4j, j2mod), prefer adding it to your driver's pom.xml, not to the shared dc3-common-driver. Drivers are runnable JARs and should not bloat each other.

Reference