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

329 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Authoring a New Driver
This guide walks you through creating a new device driver from scratch using
[`dc3-driver-virtual`](https://github.com/pnoker/iot-dc3/tree/release/dc3-driver/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 [快速开始](../quickstart/index.md) 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 [快速开始](../quickstart/index.md).
- 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`).
```bash
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`](https://github.com/pnoker/iot-dc3/blob/release/dc3-driver/pom.xml)
`<modules>`:
```xml
<modules>
<module>dc3-driver-bacnet</module>
<!-- existing entries -->
</modules>
```
Your module's own `pom.xml` only needs:
```xml
<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:
```java
@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.
```yaml
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`](https://github.com/pnoker/iot-dc3/blob/release/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:
```java
@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`](https://github.com/pnoker/iot-dc3/blob/release/dc3-common/dc3-common-constant/src/main/java/io/github/pnoker/common/constant/driver/RabbitConstant.java).
- **`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
```bash
# 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`):
```bash
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
- SDK base interface: [
`DriverCustomService.java`](https://github.com/pnoker/iot-dc3/blob/release/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/DriverCustomService.java)
- Sender service: [
`DriverSenderService.java`](https://github.com/pnoker/iot-dc3/blob/release/dc3-common/dc3-common-driver/src/main/java/io/github/pnoker/common/driver/service/DriverSenderService.java)
- Working examples: `dc3-driver-virtual` (simplest), `dc3-driver-modbus-tcp` (TCP polling),
`dc3-driver-mqtt` (push-style subscriber), `dc3-driver-listening-virtual` (push-style
template), `dc3-driver-opc-ua` (subscription model).
- Architecture overview: [模块与依赖](../architecture/modules.md)
- Troubleshooting: [故障排查](../guide/troubleshooting.md)