20 KiB
title
| title |
|---|
| 驱动开发 |
驱动开发
驱动是 IoT DC3 的南向 I/O 层:它把 Modbus、OPC UA、MQTT、S7、BACnet 等异构协议设备,统一接入到平台的数据平面和命令平面。本页带你从 dc3-driver-virtual 模板派生一个新协议驱动,并讲清驱动的生命周期、读/写调度与那条"不能随手改"的路由约束——读完你能写出一个能注册、能采集、能接受命令的驱动。
你在这里:想为一种现成驱动还不支持的协议接入设备。只想使用已有驱动,请先看 操作手册 和 快速开始。下一步可看 命令平面 理解读写命令如何流回设备。
除非特别说明,命令都在 iot-dc3 仓库根目录执行。
驱动是什么:一个聚合了 7 个 SPI 的 Spring Boot 服务
一个驱动本质上是一个独立的 Spring Boot 服务(dc3-driver-<protocol>)。它不直接和管理中心、数据中心打交道,而是继承 dc3-common-driver 这个 SDK——SDK 负责注册、调度、RabbitMQ 收发、gRPC 调用和租户上下文,你只需要实现协议逻辑。
协议逻辑通过一个入口接口暴露:DriverCustomService。它本身不声明方法,而是聚合了 7 个职责单一的 SPI 子接口,一个驱动实现这一个接口,就等于把这 7 件事都接管了:
| SPI 子接口 | 你要回答的问题 |
|---|---|
DriverLifecycle |
进程启动时初始化什么(initial())?每个自定义周期做什么(schedule())? |
DriverProtocol |
怎么从设备读一个位号(read(...))?怎么写一个位号(write(...))? |
DriverCommand |
怎么执行模板里定义的自定义命令(execute(...))? |
DriverMetadataListener |
设备/位号元数据变更时(event(...))如何刷新本地缓存? |
DriverHealth |
驱动整体在线态是 ONLINE / OFFLINE / FAULT / MAINTAIN? |
DeviceHealth |
单台设备的在线态如何判断? |
DriverValidator |
驱动/位号配置是否合法(validate*)?能否生成仿真值? |
源码入口:dc3-common/dc3-common-driver/.../service/DriverCustomService.java(一行 extends 把 7 个接口拼起来)。dc3-driver-virtual 模板把这 7 个方法都给了可运行的示例实现,是新驱动最好的起点。
::: tip 术语对齐
属性(Attribute) 来自驱动 application.yml 的 dc3.driver.*-attribute,定义"这个驱动有哪些配置项";配置(Config) 是某台设备为这些属性填的具体值,存在管理中心。驱动启动时注册属性,运行时通过 Map<String, AttributeBO> 拿到某台设备的配置值。
:::
生命周期:注册(带重试)→ initial → schedule
驱动进程启动后,DriverInitRunner(ApplicationRunner)执行一段固定的引导序列:先向管理中心注册自己和全部属性定义,注册成功后调用你的 initial() 做一次性初始化,最后由 SDK 装配定时任务(读调度、自定义调度、设备健康检查)。
注册走 gRPC,而管理中心在驱动启动时未必就绪(滚动重启、Pod 重新调度)。所以注册不是"一锤子买卖":DriverInitRunner.registerWithRetry() 用带上限的指数退避重试——初始 2 秒,每次翻倍,封顶 30 秒,最多 30 次;全部失败才抛异常退出。没有它,管理中心的一次短暂抖动就会把驱动拖进 CrashLoopBackOff。
stateDiagram-v2
[*] --> STARTING : 进程启动 DriverInitRunner.run()
STARTING --> REGISTERING : registerWithRetry()
REGISTERING --> REGISTERING : 注册失败<br/>指数退避 2s→30s, 最多 30 次
REGISTERING --> INITIAL : 注册成功<br/>driverCustomService.initial()
REGISTERING --> [*] : 30 次仍失败, 抛异常退出
INITIAL --> SCHEDULING : driverScheduleService.initialize()
SCHEDULING --> RUNNING : 读/自定义/健康任务装配完成
RUNNING --> RUNNING : 周期读 read() + 周期上报状态
源码:dc3-common/dc3-common-driver/.../init/DriverInitRunner.java(REGISTER_MAX_ATTEMPTS=30、REGISTER_INITIAL_BACKOFF=2s、REGISTER_MAX_BACKOFF=30s)。initial() 只在启动时跑一次,适合建连接池、订阅关系;schedule() 由 dc3.driver.schedule.custom 的 cron 周期触发。
从模板到新驱动:四步
新驱动的工作量集中在四处:拷贝模板、改 pom.xml、改 application.yml、实现 DriverCustomService。下图是整体路径,随后逐步展开。
flowchart LR
A["拷贝模板<br/>dc3-driver-virtual → dc3-driver-knx"] --> B["改 pom.xml<br/>继承 dc3-driver 父模块<br/>加协议库依赖"]
B --> C["改 application.yml<br/>dc3.driver code/name<br/>driver/point-attribute"]
C --> D["实现 DriverCustomService<br/>read() / write() / schedule()..."]
D --> E["package + 运行<br/>自动注册到管理中心"]
第 1 步:拷贝模板并重命名
驱动模块命名用 dc3-driver-<protocol>,协议名用 kebab-case:
cp -r dc3-driver/dc3-driver-virtual dc3-driver/dc3-driver-knx
然后重命名 Java 包、启动类和自定义服务实现类。模板里两个关键类是:
| 类 | 说明 |
|---|---|
VirtualDriverApplication |
Spring Boot 启动类 |
VirtualDriverCustomServiceImpl |
协议逻辑实现入口(implements DriverCustomService) |
新驱动应使用协议专用命名,例如 KnxDriverApplication、KnxDriverCustomServiceImpl,避免多个驱动出现重复类名。启动类与实现类放在同一父包下,确保组件扫描能找到带 @Service 的 DriverCustomService 实现:
@SpringBootApplication
public class KnxDriverApplication {
public static void main(String[] args) {
SpringApplication.run(KnxDriverApplication.class, args);
}
}
第 2 步:接入父 POM
在 dc3-driver/pom.xml 的 <modules> 中登记新模块:
<modules>
<module>dc3-driver-knx</module>
<!-- existing modules -->
</modules>
新模块自己的 pom.xml 通常只继承驱动父模块,并添加协议库依赖:
<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>
<!-- 协议库,例如 calimero-core (KNX)。重协议依赖只放这里,不要塞进 dc3-common-driver -->
</dependencies>
dc3-driver 父模块已引入 dc3-common-driver(SDK)和 Spring Boot Maven Plugin,你无需重复声明。
第 3 步:配置 application.yml
dc3.driver 是驱动最重要的用户可见配置。SDK 启动时读取它并注册到管理中心,管理侧据此渲染设备和位号的配置表单。下面以 dc3-driver-virtual 的真实结构为蓝本(把 virtual 的值换成 KNX 语义):
dc3:
driver:
tenant: default
name: KNX Driver
code: KnxDriver # 稳定路由标识,详见下文约束
type: DRIVER_CLIENT
remark: @project.description@
schedule:
read: # 读调度:周期采集位号值
enabled: true
cron: '0/30 * * * * ?' # 每 30 秒一轮
custom: # 自定义调度:驱动 schedule() 回调
enabled: true
cron: '0/5 * * * * ?'
health:
device: # 设备健康上报
enabled: true
cron: '0/15 * * * * ?'
timeout: 45 # 设备状态租约 TTL(秒)
timeout-unit: SECONDS
driver-attribute: # 驱动级属性:每个设备实例填一份
- 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: # 位号级属性:每个位号填一份
- 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
属性字段的含义(前面散文已建立心智模型,下表作速查):
| 字段 | 说明 |
|---|---|
attribute-name |
UI 显示名,驱动元数据约定用英文 |
attribute-code |
协议实现读取的稳定 key,例如 host、port、objectType |
attribute-type-flag |
属性类型,AttributeTypeEnum 共 8 值:STRING / BYTE / SHORT / INT / LONG / FLOAT / DOUBLE / BOOLEAN |
default-value |
默认值 |
remark |
说明文字,建议英文 |
::: warning 开关字段名是 enabled
调度开关字段名为 enabled:DriverScheduleServiceImpl 读取 getRead().getEnabled() / getCustom().getEnabled() / device.getEnabled(),绑定到 DriverProperties 内的 private Boolean enabled。Spring 宽松绑定不会把 enable 映射到 enabled(属于不同属性名)。dc3-driver-virtual 模板里写的是 enable,该字段实际不会生效——新驱动请用 enabled。注意 device health 的 enabled 默认为 false,需显式置 true 才启用。
:::
属性注册的链路是:application.yml 的 dc3.driver → SDK 解析为 RegisterBO → 经 gRPC 提交到管理中心。下图给出这条注册流的实体关系:
flowchart LR
YML["application.yml<br/>dc3.driver.*-attribute"] --> Props["DriverProperties<br/>(SDK 绑定)"]
Props --> Reg["RegisterBO<br/>code/name/service/tenant + 属性定义"]
Reg -->|"gRPC"| MGR["管理中心 dc3-center-manager"]
MGR --> DB[("dc3_driver_attribute<br/>dc3_point_attribute")]
MGR --> UI["管理侧渲染<br/>设备/位号配置表单"]
第 4 步:实现 DriverCustomService
核心协议逻辑放在 DriverCustomService 实现里。read(...) 返回一条 ReadPointValue,write(...) 返回 Boolean——这是协议契约的全部对外约定(源码 DriverProtocol.java):
@Slf4j
@Service
public class KnxDriverCustomServiceImpl implements DriverCustomService {
@Resource
private DriverMetadata driverMetadata;
@Resource
private DriverSenderService driverSenderService;
@Override
public void initial() {
// 一次性初始化:建立协议栈、连接池、订阅关系
}
@Override
public void schedule() {
// 自定义周期任务,例如周期上报设备状态(带 TTL)
driverMetadata.getDeviceIds().forEach(deviceId ->
driverSenderService.deviceStatusSender(
deviceId, EntityStatusEnum.ONLINE, 45, TimeUnit.SECONDS));
}
@Override
public void event(MetadataEventDTO metadataEvent) {
// 响应设备/位号元数据变更(ADD/UPDATE/DELETE),刷新本地缓存或订阅
}
@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);
// 执行协议读取,返回原始字符串值(示例值 "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) {
// 执行协议写入;仅当设备确认写成功才返回 true
return true;
}
}
::: danger 读/写失败不要静默吞异常
read() / write() 抛异常是 SDK 约定的失败信号——SDK 会记录日志并对 RabbitMQ 上的命令做 ack/nack。写命令失败时结果不会回显写入值(responseValue=null),这是为了避免"假成功"。单个位号读取失败也不应拖垮整轮采集。
:::
读/写调度:数据怎么出去、命令怎么进来
驱动有两条方向相反的数据流,都由 SDK 编排,你只填协议实现。
读(出站):Quartz 的 DriverReadScheduleJob 按 dc3.driver.schedule.read 的 cron 触发,从 DriverMetadata 缓存遍历本驱动的设备,为每台设备提交读任务(线程池),调用你的 read() 拿到 ReadPointValue,再由 SDK 经 RabbitMQ 发往数据中心。你不需要自己写 RabbitMQ 或 gRPC 管道。
写(入站):数据中心把读/写命令经 RabbitMQ 下发到本驱动的命令队列;PointCommandReceiver 做去重、按设备加锁后,反向调用你的 read() 或 write(),结果再发回数据中心。
sequenceDiagram
participant Quartz as Quartz 读调度
participant Meta as DriverMetadata 缓存
participant Impl as DriverCustomService.read()
participant MQ as RabbitMQ
participant Data as 数据中心 dc3-center-data
Note over Quartz,Data: 出站:周期采集
Quartz->>Meta: 遍历本驱动设备
Meta-->>Quartz: deviceIds
Quartz->>Impl: read(driverConfig, pointConfig, device, point)
Impl-->>Quartz: ReadPointValue
Quartz->>MQ: pointValueSender(PointValue)
MQ->>Data: 落库 dc3_point_value
Note over Data,Impl: 入站:写命令
Data->>MQ: 下发 dc3.e.point_command
MQ->>Impl: write(..., WritePointValue)
Impl-->>MQ: Boolean(true/false)
MQ->>Data: pointCommandResultSender(结果)
入站写命令在驱动侧的处理不是裸调用 write(),而是一条带校验、去重、加锁的流水线。下图展开 PointCommandReceiver 的处理管线(含错误路径):
flowchart TB
In["收到命令<br/>dc3.q.point_command.{service}"] --> Exp{"已过期?<br/>now 超过 expireAt"}
Exp -->|是| Expired["标记 EXPIRED, ack"]
Exp -->|否| Dedup{"去重命中?<br/>Caffeine 5 分钟"}
Dedup -->|命中| Dup["标记 DUPLICATE, ack"]
Dedup -->|未命中| Lock["按设备加锁<br/>DeviceLockManager (ReentrantLock)"]
Lock --> Exec["执行 read() / write()"]
Exec -->|成功| OK["回结果 SUCCESS/FAILED"]
Exec -->|异常, 首次投递| Nack["nack 重入队 + 释放去重"]
Exec -->|异常, 重投| Fail["回结果 FAILED + ack<br/>避免死循环"]
发送侧统一走 DriverSenderService(源码 DriverSenderService.java),常用方法:
| 方法 | 用途 |
|---|---|
pointValueSender(PointValue) / pointValueSender(List<PointValue>) |
发送单条 / 批量位号值 |
deviceStatusSender(deviceId, status) |
上报设备状态(默认 TTL) |
deviceStatusSender(deviceId, status, timeout, unit) |
上报带 TTL 的设备状态 |
driverAlarmSender(String) |
上报驱动级告警 |
deviceAlarmSender(deviceId, String) |
上报设备级告警 |
eventReportSender(EventReportDTO) |
上报设备事件 |
pointCommandResultSender(...) / commandResultSender(...) |
回执命令结果 |
其中 status 取值见 EntityStatusEnum:ONLINE(0) / OFFLINE(1) / MAINTAIN(2) / FAULT(3)。
::: warning 设备状态上报 TTL 必须大于读周期
设备状态以"租约"形式上报:到期未续约就判离线。TTL 必须大于状态上报/读取周期,否则设备会在两次心跳之间被判离线、反复掉线(flap)。例如读 cron 为 0/30 * * * * ?(每 30 秒),TTL 应 ≥ 25 秒;模板默认设备健康 timeout: 45 秒,留足了余量。
:::
命名与路由:哪个标识不能改
驱动路由涉及三个标识,区分清楚能避免投产后改不动的坑:
| 标识 | 来源 | 用途 |
|---|---|---|
dc3.driver.code |
application.yml |
驱动类型唯一编码,管理中心据此识别驱动类型 |
dc3.driver.service |
自动派生或显式覆盖 | 驱动实例路由标识,用于 RabbitMQ 命令队列和 routing key |
spring.application.name |
Maven artifactId | 日志文件名、Actuator 元数据等 |
::: danger dc3.driver.code 是稳定标识,变更需迁移
dc3.driver.code 一旦投产就不能随手改。它作为 driverCode 注册到管理中心、绑定该驱动类型的全部元数据——改了等于换了一个驱动类型,已接入的设备会全部失联,必须配套数据迁移方案。(RabbitMQ 命令队列与 routing key 由 dc3.driver.service 构建,不是 code,见下表。)
:::
构建、运行与冒烟验证
本地运行先加载环境变量(让本地 Java 进程指向 Compose 发布到 localhost 的依赖端口):
source dc3/env/dev.env.sh
构建新驱动及其依赖,然后运行:
::: 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
:::
开发环境下驱动会自动向管理中心注册。查看驱动日志确认出现类似 Driver register succeeded 的事件即表示注册成功(注册重试时会打印 Driver register failed on attempt n/30, retrying...)。
走通黄金路径做一次端到端冒烟(HTTP 路径与字段来自网关合约,示例值标注为示例):
- 管理侧建驱动、模板、位号、设备,并为驱动属性
host/port、位号属性groupAddress填配置值。 - 等待一个读周期(默认 30 秒)。
- 取最新位号值,确认
read()的采集已落库:
# 示例:deviceId/pointId 为示例值
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}}'
- 对可写位号下发写命令,确认
write()被调用并回执:
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"}'
接口返回 commandId,再用它查询命令历史看执行状态(PointCommandHistoryVO 的 status 取 SUCCESS/FAILED 等,写成功时 responseValue 回显写入值):
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>'
完整的命令生命周期与回执语义见 命令平面。
::: info 鉴权头怎么来
所有受保护接口都需要 X-Auth-Tenant / X-Auth-Login / X-Auth-Token。token 通过 POST /api/v3/auth/token/salt 取盐、POST /api/v3/auth/token/generate 换取(有效期 12 小时)。详见 API 文档。
:::
常见问题
| 问题 | 根因与处理 |
|---|---|
| 驱动编码冲突 | dc3.driver.code 重复——保持全局唯一且稳定,不要改已投产的 code |
DriverCustomService 未加载 |
实现类缺 @Service,或不在启动类组件扫描范围内 |
| 注册一直重试不成功 | 管理中心未就绪或 gRPC 不通——看 Driver register failed on attempt n/30 日志,确认 CENTER_MANAGER_HOST 和管理中心健康态 |
read 返回空或异常 |
不要静默吞异常,让日志暴露协议错误;单点失败不应拖垮整轮采集 |
| 设备频繁离线(flap) | 状态 TTL 小于读/上报周期——增大 TTL 或缩短调度周期 |
| 有读取但数据页无值 | 检查 RabbitMQ 连通性、数据中心日志和租户上下文 |
| 元数据变更不生效 | 在 event(...) 中更新本地协议客户端、订阅或缓存 |
| 协议依赖很重 | 只放具体驱动模块的 pom.xml,不要放进 dc3-common-driver |