从这里开始
⏱ 约 2 分钟
欢迎加入团队

这份指南是写给谁的?

如果你刚入职、第一次听到「驱动」这个词,打开代码库看到 DriverAccess、SPI、PlatformClient 一头雾水—— 别慌,这份文档就是为你写的。 我们不会假设你已经懂任何东西,每一个术语第一次出现时都会解释清楚,每一个概念都会用生活里的例子帮你理解。 读完它,你将能够独立开发出一个能接入真实设备、能把数据上报到平台的驱动。

💡 我们的目标不是让你「背下 API」,而是让你真正理解「为什么这样设计」——
理解了设计,API 自然就记住了;不理解设计,API 永远背不完。
那么,到底什么是「驱动」?

想象 EasyPro 是一个万能管控中心,要管理一整栋大楼里的所有设备:空调、灯具、传感器、PLC、网关……

问题是——这些设备「说的话」各不相同。空调说的是 Modbus 方言,灯具说的是 KNX 方言,传感器说的是 MQTT 方言,PLC 说的是 RCP 方言。管控中心不可能学会全世界所有的方言。

于是就有了驱动(Driver):它是平台和某种设备之间的「翻译官 + 代理人」。你每写一个驱动,就是给平台招了一名专门懂某门「方言」的员工,让平台能听懂这种设备、也能向这种设备下达指令。

一句话记住

驱动 = 平台与某类设备之间的「翻译官」。平台只和驱动对话,驱动负责和设备对话。

用三个生活比喻,彻底理解它
比喻一 · 翻译官
驱动是「同声传译」
设备说 Modbus,平台说 Java。驱动站在中间,把设备的报文翻译成平台能存的点位数据,把平台的指令翻译成设备能执行的协议帧。
比喻二 · 插头插座
驱动是「转接头」
平台是一个标准插座,设备是各种形状的插头(圆头、扁头、三孔)。驱动就是那个转接头,把五花八门的设备统一接进同一个平台。
比喻三 · 管家
驱动是「专属管家」
管家不只翻译,还要定期巡检(心跳)、记账(上报数据)、听从主人吩咐(下发指令)。驱动对它负责的那类设备,就是这么一位尽职的管家。
看到这里,你已经懂了驱动的本质

剩下要学的,只是「在这个平台里,怎么把这位翻译官/转接头/管家正式招聘进来、让它上岗干活」的具体规矩。而这些规矩,其实就那么几条。

这份指南怎么读?—— 学习路径地图
推荐学习路线(顺着读下来,大约 30-40 分钟)
1
3 分钟看懂全局 入门
用一张图看清「平台—驱动—设备」三方关系,理解核心概念,扫一眼术语表。不写一行代码。
约 3 分钟 · 纯阅读
2
第一个驱动 Hello World 动手
跟着步骤复制粘贴,做出一个「能跑、能上报假数据」的最小驱动,建立信心和成就感。
约 10 分钟 · 跟着做
3
核心机制 + 能力三件套 理解
搞懂 DriverAccess 接入、Web 接口、定时任务、PlatformClient 这几样「工具」分别干什么、什么时候用。
约 12 分钟 · 重点章节
4
完整开发流程 + 部署 实战
把零散知识串成一个完整流程:搭项目 → 实现接口 → 打包 → 上线。这是日后日常工作的主流程。
约 8 分钟 · 查阅用
5
最佳实践 + FAQ 进阶
踩坑总结和常见疑问。开发遇到问题时回来翻这一节,能省下大量排查时间。
按需查阅
建议的阅读姿势

第一遍顺着读下来建立全貌;之后把这份页面当「字典」用——开发时遇到具体问题,直接从左侧目录跳到对应章节即可。每个章节都尽量做到独立可读。

1
3 分钟看懂全局
⏱ 约 3 分钟 · 不写代码
本章目标

用一张图,看清驱动在整个系统里的位置

很多新人学驱动开发觉得难,不是因为代码多复杂,而是因为一开始没建立起全局画面——不知道自己写的这几行代码,最终在系统里扮演什么角色。这一节,我们先把这张「地图」摊开。

一个比喻,串起所有概念

把整个系统想象成一家「智能楼宇服务公司」,那么:

  • EasyPro 平台 = 公司总部,负责接单、记账、派活、发工资(统一管理)。
  • 设备(空调/灯具/PLC)= 分布在各处的外包工人,他们各有各的工作习惯和「方言」。
  • 驱动 = 公司派驻在每个外包工人身边的现场代表,负责翻译沟通、监督工作、定期汇报。
  • DriverAccess = 现场代表的上岗合同,规定了必须会做的几件事(报到、报工号、开工、接单……)。
  • PlatformClient = 公司发给现场代表的对讲机 + 工单系统,用它才能向总部汇报、查资料、领任务。
  • 点位(Point) = 公司关注的每一项工作数据,比如「3 楼空调当前温度」「5 楼灯的开关状态」。
  • 驱动实例(DriverInstance) = 现场代表负责的一个具体岗位(比如「负责 3 号楼所有 Modbus 空调」是一个实例)。
关键关系

一种协议 → 一个驱动(modbus 驱动负责所有 Modbus 设备);一组同类设备 → 一个驱动实例(3 号楼的 Modbus 空调是一个实例,5 号楼的 Modbus 电表是另一个实例,但都用同一个 modbus 驱动)。

架构全景图:一次数据上报的旅程
数据源头
设备
空调 / 灯具 / PLC / 传感器
各自说不同的「方言」
(Modbus / KNX / MQTT …)
翻译上报
你负责这里
驱动 + SDK
你写的驱动翻译设备数据
通过 PlatformClient 上报
靠 DriverAccess 接入平台
存储展示
价值呈现
平台 + 用户
EasyPro 存储数据、下发指令
用户在界面看数据、控设备
报表 / 告警 / 联动
左右两边(设备和平台)都是现成的,你只负责中间这块——把设备数据翻译成 Point,再调 pushPointValue 上报。这就是驱动工作的全部核心。

举个例子:一台空调当前温度是 24.5℃,它用 Modbus 协议把这个数字「说」出来。你写的 modbus 驱动收到后,翻译成平台认识的 Point 对象,通过 PlatformClient.pushPointValue() 这个「对讲机」上报给平台。平台存下来,用户在界面上就看到了「24.5℃」——这一连串就是一次完整的数据旅程。

而你,就是中间那个翻译官。左右两边(设备和平台)都是现成的,你的工作就是把它们连起来。

为什么平台要这么设计?(理解了就不再觉得别扭)
设计动机揭秘

新人常有疑问:「为什么驱动要实现这么多方法?为什么不能自己连数据库、自己开线程?非得受这些限制?」答案是为了「统一管理、隔离故障」。

想象公司有 7 个现场代表(7 种驱动),如果每个人都能随便动公司的账本和公章(数据库、线程池),一个人犯错全公司遭殃。所以平台定了规矩:

  • 资源统一管:线程池由平台注入(driverExecutor),数据库访问通过 PlatformClient 出口——这样平台能控制总量、监控使用、隔离故障。
  • 接口标准化:所有驱动都实现 DriverAccess 这一份「合同」(17 个方法),平台用同一套方式管理所有驱动,加新驱动不用改平台代码。
  • 不使用 Spring:驱动不依赖 Spring 框架,靠 SPI 加载。这不仅是减少耦合,更是为了让 SDK 能独立成包——未来驱动可以打成 jar 上传平台加载、可以声明属性元数据让平台自动入库(详见第 3 章设计理念)。个别驱动的违规用法后续会修正。

这种「受约束的自由」看似啰嗦,实则是大规模系统稳定运行、也是驱动生态可独立交付的基础。理解了这一点,后面看到的各种规矩就都顺理成章了。

术语速查表(遇到不懂的随时回来查)
DriverAccess
驱动接入接口
每个驱动必须实现的「上岗合同」,共 17 个方法(身份、校验、生命周期、业务四大类)。实现它,平台才认你。
PlatformClient
平台客户端
平台发给驱动的「对讲机」,用它才能上报数据、查设备、开线程。驱动和平台的所有交互都走它。
DriverInstance
驱动实例
驱动负责的一个具体「岗位」,比如「3 号楼的 Modbus 设备组」。一个驱动可以管很多个实例。
Point / 点位
数据点
平台关心的最小数据单元,比如「空调当前温度」「灯开关状态」。上报和下发都是围绕点位。
SPI
Service Provider Interface
Java 的服务发现机制。在 META-INF/services 文件里写下实现类的全路径,平台用 ServiceLoader 启动时自动找到并加载你的驱动。
HttpApiCallBack
HTTP 接口回调
驱动想对外提供 HTTP 接口(比如「导入配置文件」)时实现的接口。平台自动把 /driver/api/{驱动名}/{接口名} 路由到你的代码。
ScheduleCallback
定时任务回调
驱动想做周期性任务(比如每 4 秒心跳检测)时实现的接口。平台会注册到调度系统(任务全名 {驱动名}_{任务名}),驱动只管写执行逻辑。
内嵌 / 外置
Embedded / External
驱动的两种部署方式。内嵌=代码在主工程里(当前唯一启用);外置=打成 jar 上传加载(设计愿景,SDK 独立成包 + 声明式元数据,目前 todo 未实现)。
心跳
KeepAlive
驱动定期告诉平台「我还活着」。平台超时收不到心跳,就把对应实例标记为离线。
Cron
定时表达式
定义「多久执行一次」的字符串,格式为「秒 分 时 日 月 周」,如 0/4 * * * * ? 表示每 4 秒。
质量码
statusFlag
点位数据的可信度标记。正常数据通常设为 1(见各驱动 setStatusFlag(1))。上报数据时带上,让平台知道这个值能不能信。
DriverApiResult
API 响应结果
HTTP 接口返回给前端的统一格式,包含 success、errorMsg、data 三个字段。
概念入门达成 Lv.1
你已经理解了驱动是什么、整个系统怎么协作、为什么这么设计。接下来我们动手——10 分钟做出你的第一个能跑的驱动。
2
第一个驱动 · Hello World
⏱ 约 10 分钟 · 跟着做
本章目标

做出一个能跑、能上报数据的「最小驱动」

学编程最好的方式永远是「先让它跑起来」。这一节我们不追求完整、不追求优雅,只做一件事:用最少的代码,让平台认识你的驱动、并看到它上报了一条数据。跑通这一遍,你会有「啊,原来如此」的顿悟感。

动手之前:你要准备什么
前置准备清单
JDK 21,能在命令行运行 java -version 看到 21.x 版本号
Maven 3.6+,能运行 mvn -version
一个 IDE(IntelliJ IDEA 推荐,Eclipse 也行)
能编译运行的 EasyPro 平台源码(问导师要,或从内部 Git 拉取)
这篇文档开着,遇到不懂的随时翻术语表
说明

这里我们用「内嵌模式」——把驱动代码直接放进平台主工程来开发。这是最适合新人的方式:能断点调试、改完立刻生效、不用操心打包。等熟练了,再看外置模式。

动手四步,做出你的驱动
1
引入驱动 SDK 依赖
让代码认识 DriverAccess
这一步在做:准备工具箱

驱动 SDK(com.sctech.opti.cloud.drivers.sdk 包)就在 OptiCloud 主工程内部,驱动和平台是同一个 Maven 工程,所以不需要额外加 SDK 的 Maven 坐标——直接 import 即可。你只需确保 Lombok 可用(主工程已包含):

铁律:驱动不使用 Spring

虽然驱动代码在主工程里(能「看到」Spring 的类),但驱动代码严禁 import 或使用任何 Spring 的东西(@Component/@Service/@Autowired/@Resource、org.springframework.* 等)。驱动靠 SPI 加载,资源通过 PlatformClient 获取。这是设计原则,违反会导致驱动无法被正确加载和管理。

java
// 直接 import SDK 中的接口,无需任何 Maven 坐标
import com.sctech.opti.cloud.drivers.sdk.DriverAccess;
import com.sctech.opti.cloud.drivers.sdk.PlatformClient;
import com.sctech.opti.cloud.drivers.sdk.HttpApiCallBack;
import com.sctech.opti.cloud.drivers.sdk.ScheduleCallback;
import com.sctech.opti.cloud.drivers.sdk.model.DriverInstance;
import com.sctech.opti.cloud.drivers.sdk.model.Point;
2
创建你的驱动接入类
实现 DriverAccess「上岗合同」
这一步在做:写驱动主体

新建一个类,实现 DriverAccess 接口。这是驱动的「身份证」——平台靠它认识你。注意:DriverAccess 接口一共有 17 个方法,下面先给出最核心的几个,其余校验/生命周期方法先空实现(IDE 一键生成即可),第 3 章会逐一讲:

java
@Slf4j
public class HelloDriverAccess implements DriverAccess {

    // 第 3 步会用到的「对讲机」,先声明着
    @Getter
    private static PlatformClient platformClient;

    @Override
    public boolean init() {
        log.info("🎉 我的第一个驱动,初始化成功!");
        return true;   // 返回 true 表示初始化成功
    }

    @Override
    public String getDriverVersion() { return "V 1.0.0"; }

    @Override
    public String getDriverName() { return "hello"; }   // 驱动名,要和数据库一致

    @Override
    public DriverAccessMode getAccessMode() { return DriverAccessMode.EMBEDDED; }

    @Override
    public List<HttpApiCallBack> getHttpApiCallBackList() { return List.of(); }

    @Override
    public List<ScheduleCallback> getScheduleCallbackList() {
        return List.of(new HelloReportTask());  // 第 3 步创建
    }

    @Override
    public void receivePlatformClient(PlatformClient client) {
        platformClient = client;   // 平台把「对讲机」发给我们,存起来
        log.info("📞 收到平台客户端");
    }

    // —— 以下方法先空实现,IDE 可一键生成;真实驱动需补充逻辑(见第 6 章)——
    @Override public void validateDriverInstanceAttr(List<DriverInstanceAttr> l, Long i, String p) {}
    @Override public void validateDriverPointAttr(List<PointAttr> l, Long i, Long p, String pr) {}
    @Override public void issuePointValueBatch(List<Point> l) {}
    @Override public boolean openDriverInstance(DriverInstance d) { return true; }
    @Override public boolean closeDriverInstance(DriverInstance d) { return true; }
    // ... 其余 newDriverInstanceHasSaved / driverInstanceHasUpdated /
    //     driverInstanceHasDeleted / getUniqueCodeByPointFromDriver / 批量校验等
    //     先空实现,详见第 3、6 章
}
这几个核心方法是干嘛的?

不用现在全记住,这里先有个印象,第 3 章会逐一细讲。现在你只需知道:

  • init() = 报到,「我来了,准备好了」
  • getDriverName() = 工号牌,平台按这个名字找你
  • receivePlatformClient() = 领对讲机,存好以后用
  • 其他几个先填默认值即可
3
写一个定时上报任务
让驱动每隔几秒「说话」
这一步在做:让驱动动起来

一个静止的驱动没意思。我们让它每 4 秒上报一条数据,这样你能在平台上亲眼看到数据在动:

java
@Slf4j
public class HelloReportTask implements ScheduleCallback {

    @Override
    public String getTaskName() { return "helloReportTask"; }

    @Override
    public String getCronExpression() {
        return "0/4 * * * * ?";   // 每 4 秒执行一次
    }

    @Override
    public String getTaskDescription() { return "hello 驱动测试上报"; }

    @Override
    public void execute() {
        log.info("📊 hello 驱动定时任务执行中...");
        // 真实驱动会在这里读取设备数据,这里先模拟
        // 用 platformClient.pushPointValue(...) 上报即可
    }
}
4
登记你的驱动(SPI 声明)
让平台启动时发现你
这一步在做:把身份证录进系统

最后一步,也是最容易被忘的一步:在 META-INF/services/ 目录下创建一个文件,文件名是接口全限定名,内容是你的实现类全限定名。平台启动时会扫描这里:

文件路径
src/main/resources/META-INF/services/
└── com.sctech.opti.cloud.drivers.sdk.DriverAccess   # 文件名固定为这一长串
文件内容
com.sctech.opti.cloud.drivers.inline.hello.access.HelloDriverAccess   # 你的实现类全路径,每行一个
90% 的新人都会栽在这里

如果启动后日志里看不到你的 🎉 我的第一个驱动,第一个就检查这个文件:文件名对不对、类路径对不对、有没有拼写错误。这一步没有报错提示,只会「静默地不加载」。

启动平台,看效果!

启动 EasyPro 平台,打开控制台日志,如果一切正常,你会看到:

控制台日志
[INFO] 🎉 我的第一个驱动,初始化成功!
[INFO] 📞 收到平台客户端
[INFO] 📊 hello 驱动定时任务执行中...    ← 每 4 秒出现一次
[INFO] 📊 hello 驱动定时任务执行中...
[INFO] 📊 hello 驱动定时任务执行中...

看到这三行日志,恭喜——你的驱动已经被平台成功加载并运行起来了!虽然它还没接真实设备、还没上报真实点位,但「驱动开发」最关键的那道坎(让平台认识并运行你的代码)你已经跨过去了。

第一个驱动跑起来了! Lv.2 见习工程师
你已经完成了从 0 到 1 的突破。接下来要做的,是搞清楚刚才那些代码「为什么这么写」——这就是后面几章的内容。
想一想
如果启动后看不到「🎉 我的第一个驱动」这行日志,最可能的原因是什么?
提示:回头看第 4 步那张图,最容易出错的是哪个文件?
点击看答案
最可能是 META-INF/services/com.sctech.opti.cloud.drivers.sdk.DriverAccess 这个 SPI 声明文件没创建、文件名写错、或里面的实现类全路径写错。SPI 加载失败是「静默」的,不会报错,只会让你的驱动不被加载。这是新人第一大坑。
3
核心机制详解 · DriverAccess
⏱ 约 8 分钟 · 重点
本章目标

彻底搞懂 DriverAccess 这份「上岗合同」

上一章你照着抄了一份驱动代码跑起来了,但那些方法到底为什么要有、每个方法在什么时候被调用、返回值意味着什么,可能还一头雾水。这一章我们把 DriverAccess 掰开揉碎讲清楚——它是驱动开发的地基,地基打牢,后面盖楼就快了。

设计理念:为什么驱动要这么「轻」?

EasyPro 驱动框架有一条核心原则:驱动不使用 Spring,也不依赖任何平台端包,只关注业务实现。资源管理(线程池、数据库访问)通过 SDK 的 PlatformClient 统一出口,驱动专注业务实现,把「重活」托管给平台。

关于原则的说明

「驱动不使用 Spring」是必须遵守的设计原则。如果你在参考现有驱动代码时看到有人用了 Spring 注解,请将其视为待修正的违规写法,不要照抄——这并不代表原则发生了变化。新开发驱动时务必严格遵守:不引入 Spring 依赖。

为什么非要这么「轻」?—— 一切为了未来的外置驱动

驱动不依赖 Spring、只通过 SDK 接口与平台交互,这个设计不是单纯的洁癖,而是有明确的战略目的:

① SDK 能独立成包。因为驱动只依赖 SDK 这一层(不依赖整个平台),未来这套 SDK 就能抽成一个独立的开发包,提供给任何人在自己的工程里开发驱动,不需要拉整个 OptiCloud 源码。

② 支撑外置驱动 jar 加载。开发者用 SDK 包写好驱动、打包成 jar,上传到平台即可直接加载运行——不用改主工程、不用重新发布平台。这正是 外置模式的设计目标(目前内嵌先行,外置是下一阶段)。

③ SDK 声明元数据,平台自动建表。SDK 会提供声明方法,让驱动声明自己需要哪些实例属性、点位属性等元数据(比如「我这个驱动需要 IP、端口、波特率三个实例属性」「点位需要寄存器地址、功能码两个属性」)。驱动一加载,平台就会自动把这些元数据构建到数据库,前端据此生成配置界面——驱动开发者不用手写建表 SQL,不用手动配数据库。

所以,「轻量化」的真正含义是「可移植、可独立交付」。今天你按内嵌模式写的驱动代码,将来 SDK 独立成包、外置加载实现后,几乎可以原封不动地打成 jar 上传运行。理解了这个长远目标,就能明白现在那些「不用 Spring、走 PlatformClient」的规矩为什么必须从一开始就遵守。

轻量化驱动不依赖 Spring,只依赖 SDK 一层
可独立打包SDK 能抽成独立开发包,供外部开发
jar 上传加载未来打成 jar 上传平台即可运行(外置模式)
声明式元数据SDK 声明属性元数据,平台自动入库
DriverAccess 的方法(逐个讲明白)

DriverAccess 接口一共有 17 个方法,分四大类。新人一开始可能会被数量吓到,但其实大部分都有默认空实现,真正需要你认真写的就那么几个。我们按「现场代表上岗」的比喻分类讲:

四类方法速览

① 身份类(5个):报姓名版本工种;② 校验类(4个):验证配置合法性;③ 生命周期类(5个):实例开关删改;④ 业务类(3个):下发点位、获取唯一码、领对讲机。

身份类 · 5 个
驱动加载时
init() getDriverName() getDriverVersion() getAccessMode() getHttpApiCallBackList() getScheduleCallbackList()
校验类 · 4 个
保存/导入配置时
validateDriverInstanceAttr() validateDriverPointAttr() validateDriverInstanceAttrBatch() validateDriverPointAttrBatch()
生命周期类 · 5 个
实例开关删改时
openDriverInstance() closeDriverInstance() newDriverInstanceHasSaved() driverInstanceHasUpdated() driverInstanceHasDeleted()
业务类 · 3 个
运行时交互
receivePlatformClient() issuePointValueBatch() getUniqueCodeByPointFromDriver()
新人起步只需关注

第一遍读,重点搞懂身份类(init/getDriverName/receivePlatformClient)+ openDriverInstance/closeDriverInstance + issuePointValueBatch 这几个,其余先空实现,遇到需求再回查。

① 身份类方法(必须实现,最基础)

1
boolean init()
报到。驱动被加载时平台第一个调用的方法,你在这里做初始化准备(建连接池、读配置等)。
  • 返回 true = 「我准备好了,可以开工」
  • 返回 false = 「出问题了,别给我派活」
  • 这是你给平台留下的第一印象,建议在这里打印一条 INFO 日志方便排查
2
String getDriverVersion()
报工号版本。返回版本号字符串,如 "V 1.2.0"。用于版本管理和兼容性检查。
3
String getDriverName()
报名字。这个名字必须和数据库里配置的驱动名称完全一致(大小写敏感)。平台靠它把「数据库里的配置」和「你写的代码」对应起来。
  • 通常用小写字母,如 "modbus"、"rcp"、"hello"
  • 写错了 = 平台找不到你 = 驱动永远不会被加载
易错点

这个名字是字符串,拼错一个字母、大小写不对,都不会报错,只会「找不到」。如果驱动加载了但实例打不开,优先检查这里和数据库是否一致。

4
DriverAccessMode getAccessMode()
报工种。返回内嵌或外置:
  • EMBEDDED(内嵌)= 代码在主工程里,当前唯一真正启用的方式
  • EXTERNAL(外置)= 设计上预留,但目前未实现
5
List<HttpApiCallBack> getHttpApiCallBackList()
报服务清单。告诉平台「我能提供哪些 HTTP 接口」。平台会自动把这些接口注册成 URL。第 4 章详解。
  • 不需要就返回 List.of()(空清单)
6
List<ScheduleCallback> getScheduleCallbackList()
报定时任务清单。声明驱动要跑哪些周期任务(心跳、轮询等)。平台会注册到调度系统。第 4 章详解。
  • 不需要就返回 List.of()

② 校验类方法(验证配置合法性,防止错误配置)

7
void validateDriverInstanceAttr(List<DriverInstanceAttr>, Long instanceId, String projectId)
校验单个实例属性。用户保存实例配置时,平台调这里让你校验(比如 IP 格式、端口范围)。抛异常 = 配置不合法,保存不上去。
8
void validateDriverInstanceAttrBatch(List<ImportInstanceResultVo>, ImportResult)
批量校验导入的实例。用户批量导入工程文件时调用,把整批实例的校验结果填入 ImportResult。
9
void validateDriverPointAttr(List<PointAttr>, Long, Long, String)
校验单个点位属性。比如 Modbus 的寄存器地址是否合法、数据类型是否支持。
10
void validateDriverPointAttrBatch(List<ImportPoint>, ImportResult, Long, String)
批量校验导入的点位。批量导入点位时调用。

③ 生命周期方法(实例的开关删改,详见第 6 章)

11
boolean openDriverInstance(DriverInstance) / closeDriverInstance(DriverInstance)
实例开启时建连接、启动轮询;关闭时断开连接、释放资源。
12
boolean newDriverInstanceHasSaved(DriverInstance) / driverInstanceHasUpdated(DriverInstance) / driverInstanceHasDeleted(Long)
实例新建/更新/删除后的回调通知。删除时务必清理连接等资源。

④ 业务与交互方法

13
void receivePlatformClient(PlatformClient)
领对讲机。平台加载你时,会把 PlatformClient 注入进来——这是你与平台交互的唯一通道。务必存成静态变量,整个驱动生命周期都要用。
14
void issuePointValueBatch(List<Point>)
下发点位值。平台要控制设备时(比如「把空调温度设为 26 度」),会调这里,你把指令翻译成协议帧发给设备。
15
String getUniqueCodeByPointFromDriver(Point)
生成点位唯一码。某些协议(如 BACnet)需要驱动自己根据点位属性生成唯一标识。简单协议可返回 null。
记忆口诀

DriverAccess 17 个方法看似很多,其实就四件事:「我是谁」(身份类)+ 「配置对不对」(校验类)+ 「实例开关删改」(生命周期)+ 「下发指令、领对讲机」(业务交互)。IDE 一键生成空实现,再按需填充即可,不必死记。

部署方式:内嵌(当前唯一启用)

驱动代码放在主工程内部,通过 SPI 声明,平台用 ServiceLoader.load(DriverAccess.class) 自动发现加载。这是目前唯一真正启用的方式——所有 7 个在线驱动(modbus/knx/mqtt/rcp/secp/ycbox/bacnetip)都是内嵌的。

内嵌模式(Embedded)—— 当前唯一启用

驱动代码直接在主工程 drivers/inline/<驱动名>/ 目录下,在 SPI 文件里声明实现类。平台启动时通过 ServiceLoader 自动加载。

当前 SPI 文件内容(7个启用驱动)
com.sctech.opti.cloud.drivers.inline.ycbox.access.YcboxDriverAccess
com.sctech.opti.cloud.drivers.inline.rcp.access.RcpDriverAccess
com.sctech.opti.cloud.drivers.inline.modbus.access.ModbusAccess
com.sctech.opti.cloud.drivers.inline.secp.access.SecpAccess
com.sctech.opti.cloud.drivers.inline.knx.access.KnxAccess
com.sctech.opti.cloud.drivers.inline.mqtt.access.MqttDriverAccess
com.sctech.opti.cloud.drivers.inline.bacnetip.access.BacnetIpAccess
启用/禁用怎么控制?

SPI 文件里每行一个实现类全路径,ServiceLoader 会加载所有非注释行。要临时禁用某个驱动,在行首加 # 注释掉即可;新增驱动就在文件末尾追加一行。当前启用的就是上面这 7 个。

SPI 是什么?

SPI(Service Provider Interface)是 Java 自带的服务发现机制:在 META-INF/services/ 放一个「以接口名为文件名」的文本文件,里面每行写一个实现类的全路径,ServiceLoader 启动时自动扫描并加载。不用手动 new,不用改配置。

外置模式(External)—— 设计愿景,目前未实现

DriverAccessMode 枚举里定义了 EXTERNAL(外置模式),框架的 DriverAccessLoader 里也预留了 watch() 目录监听方法的骨架。但这部分目前标记为 //todo,尚未实现——所以现阶段所有驱动都走内嵌模式。

外置模式实现后的愿景(也就是设计理念里说的战略目标):

  • SDK 抽成独立开发包,第三方拉这个包就能开发驱动,不用拉 OptiCloud 全量源码
  • 驱动写好打包成 jar 上传平台,平台自动加载运行,不用改主工程、不用重新发布
  • 驱动通过 SDK 声明属性元数据(实例属性、点位属性),平台自动构建到数据库、生成配置界面
给新人的提醒

这些是规划中的能力,当前版本不可用。开发时统一用内嵌模式。但现在写驱动就要按 SDK 规范来(不用 Spring、走 PlatformClient),这样将来外置模式上线,代码能直接复用。等外置模式真正实现后,本文档会同步更新。

为什么现在外置还没实现,却要强调内嵌也要守规矩?

因为驱动的代码是同一套。今天你按内嵌模式写的驱动(不依赖 Spring、只走 SDK 接口),将来外置模式上线后,几乎可以原封不动地打成 jar 上传运行。如果现在图方便用了 Spring、直接碰了平台内部类,那将来外置化时就得返工重写。

所以:内嵌是当下的运行方式,SDK 规范是面向未来的代码标准。两者不矛盾——用内嵌的方式开发,按外置的标准写代码。

核心机制掌握 Lv.3
现在你完全理解了 DriverAccess 这份合同。下一章讲平台给驱动的「三件能力」——HTTP 接口、定时任务、平台客户端,它们让你的驱动真正强大起来。
4
能力三件套
Web接口 · 定时任务 · 平台客户端
本章目标

掌握平台赋予驱动的三大能力

实现 DriverAccess 只是「上岗」,真正让驱动干活的是平台给你的三件工具:对外提供 HTTP 接口、跑定时任务、调用平台能力。搞懂这三样,你就能应付 90% 的开发需求。

导师,驱动上岗后,具体能干哪些事啊?感觉心里没底。
就三件大事:① 对外开接口(让前端能调你,比如「导入配置」);② 跑定时任务(周期性干活,比如心跳、轮询);③ 调平台能力(上报数据、查设备,靠 PlatformClient)。我们一个个说。
① Web 接口映射:让前端能调用你的驱动
这是什么?什么时候用?

有时候驱动需要对外提供 HTTP 接口,让前端或第三方系统能调用。比如 Modbus 驱动要提供「导入点位配置文件」接口,BACnet 驱动要提供「触发设备发现」接口。

但你不用自己写 Controller、不用自己配路由——实现 HttpApiCallBack 接口,平台会自动把 URL 注册好,请求转发到你的代码里。

HttpApiCallBack 接口定义
java
public interface HttpApiCallBack {

    /*
     * 返回 API 的映射路径
     * 比如返回 "importDriverInstance"
     * 最终 URL 会是 /driver/{driverName}/importDriverInstance
     */
    String getApiMapping();

    /*
     * 执行 API 请求
     * @param param 请求参数 Map(所有查询参数)
     * @param file   上传的文件(可能为 null)
     * @return DriverApiResult 封装执行结果
     */
    DriverApiResult execute(Map<String,String> param, File file);
}
完整示例:实现一个「导入驱动实例」接口
java
@Slf4j
public class ImportDriverInstanceApi implements HttpApiCallBack {

    @Override
    public String getApiMapping() {
        return "importDriverInstance";  // URL 路径名
    }

    @Override
    public DriverApiResult execute(Map<String,String> param, File file) {
        if (file == null) {
            throw new IllegalArgumentException("配置文件不能为空");
        }
        Long driverId = Long.parseLong(param.get("driverId"));
        // ... 解析 file、导入配置的具体逻辑
        DriverApiResult r = new DriverApiResult();
        r.setData("导入成功");
        return r;
    }
}

然后在驱动的 getHttpApiCallBackList() 里把它注册上:

java
@Override
public List<HttpApiCallBack> getHttpApiCallBackList() {
    return List.of(new ImportDriverInstanceApi());
}

这样前端就能通过 POST /driver/api/modbus/importDriverInstance 调用到你的代码了(注意路径里有 /api 段)。不用写 Controller,不用配路由,全自动化。

工作流程与路由规则(理解就好,不用记)

① 驱动实现接口返回路径名 → ② 平台启动时在 DriverAccessLoader 里收集,按 {driverName}/{apiMapping} 拼成 key 存入 apiCallBackMap → ③ 平台的 DriverApiController(类级 @RequestMapping("/driver/api"))用 @PostMapping("/{driverName}/{api}") 接收请求 → ④ 转发给 driverHelper.executeDriverApi() 找到你的 execute 方法 → ⑤ 结果包装成 Result 返回前端。完整 URL:/driver/api/{driverName}/{apiMapping}。

② 定时任务托管:让驱动周期性干活
典型应用场景
心跳检测定期检查实例健康状态并上报
数据同步周期性从设备拉取数据
超时清理清理超时的连接或会话
批量操作攒够一批数据后批量处理
ScheduleCallback 接口定义
java
public interface ScheduleCallback {
    String getTaskName();          // 任务名(驱动内唯一,不能重复)
    String getCronExpression();   // Cron 表达式:多久执行一次
    String getTaskDescription(); // 任务描述(给人看的)
    void execute();              // 真正要干的活
}
任务是怎么被调度起来的?(平台内部机制)

驱动返回 ScheduleCallback 列表后,平台在 DriverAccessLoader 里会调 scheduleHelper.createSysSchedule() 把任务注册进调度系统。注册时:任务全名 = {driverName}_{taskName}(比如 rcp 驱动的 aliveTask 全名是 rcp_aliveTask),bean 固定为 driverHelper,方法为 executeDriverScheduleTask,Cron 用你返回的表达式。所以任务名驱动内不能重复,否则会互相覆盖。

Cron 表达式速查(「秒 分 时 日 月 周」六位)

新人最怕 Cron,其实就背几个常用的就行:

需求表达式含义
每 4 秒0/4 * * * * ?心跳检测常用
每 10 分钟0 0/10 * * * ?数据同步常用
每小时整点0 0 * * * ?周期巡检
每天中午 12 点0 0 12 * * ?日报类任务
每月 1 号凌晨 2 点0 0 2 1 * ?月度任务

字段允许值:秒/分 0-59,时 0-23,日 1-31,月 1-12,周 SUN-SAT。特殊字符 * , - / 通用,日还支持 ? L W,周支持 ? L #。

实现示例:RCP 驱动心跳任务
java
@Slf4j
public class AliveTask implements ScheduleCallback {

    private static final PlatformClient platformClient = RcpDriverAccess.getPlatformClient();

    @Override
    public String getTaskName() { return "aliveTask"; }

    @Override
    public String getCronExpression() { return "0/4 * * * * ?"; }  // 每 4 秒

    @Override
    public String getTaskDescription() { "rcp 驱动实例心跳检测"; }

    @Override
    public void execute() {
        log.debug("心跳检测开始");
        RcpConnectorManager.checkDriverInstanceAlive();
        log.debug("心跳检测完成");
    }
}

同样,在驱动的 getScheduleCallbackList() 里注册:return List.of(new AliveTask());

③ PlatformClient:驱动调用平台能力的「对讲机」
为什么需要它?

前面说过,驱动不能自己连数据库、自己开线程——所有和平台的交互,都必须通过 PlatformClient 这个统一出口。它就像公司发的对讲机+工单系统:

统一出口所有交互只走这一个客户端
权限控制平台能对驱动鉴权、限流
解耦驱动不知道平台内部细节
易扩展加 API 只需加方法
核心 API 分类速览(不用全记,用到时回来查)
驱动数据与实例管理
  • getDriverData() — 获取当前驱动的全部数据(实例、点位、属性、配置),心跳/巡检任务常用
  • getDriverInstance(Long id) — 获取单个驱动实例
  • createNewDriverInstance(...) / updateDriverInstance(...) / deleteDriverInstance(Long) — 创建 / 更新 / 删除实例
  • pushInstanceKeepAlive(List<InstanceStatusToPush>) — 推送心跳(心跳任务必调)
设备类型管理
  • getDeviceType(projectId, name) / saveDeviceType(...) / batchUpdateDeviceType(...)
  • updateDeviceTypeDescription(...) / deleteDeviceTypesByKeys(List<Long>, Long)
设备管理
  • getDevicesByDeviceTypeKey(Long) / getDevicesByTypeKeys(...) / getDevicesByDriverInstanceIdRelative(...)
  • saveDeviceBatch(...) / batchUpdateDevice(...)
  • deleteDeviceBath(...) / deleteDeviceByIds(...) — 批量删除(注意源码方法名是 deleteDeviceBath)
点位管理(上报数据的核心)
  • getPointsByDriverInstanceId(Long) — 获取点位列表
  • saveBatchPoint(List<Point>, boolean autoBinding) / updateBatchPoint(...) — 批量保存 / 更新点位
  • pushPointValue(List<Point>) — 推送点位值(驱动最常调用的方法!)
  • checkPointCanAdd(projectId, count, alarmSleepSeconds) — 检查点位是否可添加
  • deletePoints(List<Long>) / deleteAllPointByDriverInstanceId(Long) — 删除点位
告警与历史数据
  • pushAlarm(DriversAlarm) — 推送告警
  • uploadHistoryData(List<HistoryPointValue>) — 上报历史数据
  • updateDriverExtraData(driverId, extraData) — 更新驱动扩展数据
线程池
  • getDriverThreadPoolExecutor() — 获取驱动专用线程池(平台注入的 driverExecutor,不要自己 new Thread)
数据上报全景:一条数据从设备到用户界面

理解 PlatformClient 最快的方式,就是看一条点位数据是怎么「走」完整个链路的。下图是一次数据上报的完整旅程:

1
设备产生数据
空调读到 24.5℃,通过 Modbus 报文发出
2
驱动接收 + 翻译
你写的驱动解析报文,构造 Point 对象,填好 curValue 和 statusFlag
3
调用 PlatformClient
platformClient.pushPointValue(pointList) —— 把数据交给平台
4
平台存储
平台落库,更新实时值,触发告警/历史记录
5
用户看到
前端界面显示「24.5℃」,报表/告警生效
你的职责就是第 2、3 步

第 1 步(设备)和第 4、5 步(平台/前端)都是现成的。你只需把设备数据翻译成 Point,再调 pushPointValue——这就是驱动上报数据的全部核心。下发指令(issuePointValueBatch)是反向的同一链路。

使用示例:上报点位 + 异步处理
java
// 获取驱动实例
DriverInstance instance = platformClient.getDriverInstance(instanceId);

// 推送点位值到平台(最核心的调用)
platformClient.pushPointValue(pointList);

// 用平台线程池跑异步任务(千万别自己 new Thread)
platformClient.getDriverThreadPoolExecutor().execute(() -> {
    // 异步处理逻辑
});
线程池的铁律

平台给每个驱动分配了专用线程池,异步任务必须用 getDriverThreadPoolExecutor()。自己 new Thread().start() 是严重违规——会导致线程失控、平台无法管理、故障无法隔离。

❌ 错误写法
java
// 自己开线程,平台管不到
new Thread(() -> {
    doSomething();
}).start();
✅ 正确写法
java
// 用平台线程池,可管控可监控
platformClient
    .getDriverThreadPoolExecutor()
    .execute(() -> doSomething());
能力三件套掌握 Lv.4
HTTP 接口、定时任务、PlatformClient——这三样组合起来,你已经能开发出功能完整的驱动了。后面几章是数据模型、完整流程、部署和最佳实践,属于「查漏补缺 + 进阶」。
5
数据模型说明
实例 · 点位 · 响应

驱动开发会反复接触三个核心数据模型。它们都位于 com.sctech.opti.cloud.drivers.sdk.model 包下,用 Lombok @Data 简化代码,支持链式调用。理解了这三个,PlatformClient 的 API 就一目了然了。

记忆方式

把这三个模型想成「岗位(DriverInstance)— 工作项(Point)— 工作汇报(DriverApiResult)」的层级关系。

DriverInstance(驱动实例)—— 你的「岗位」

代表驱动负责的一组设备,比如「3 号楼所有 Modbus 空调」。

java
@Data
public class DriverInstance {
    private Long instanceId;          // 实例 ID(主键)
    private Long driverId;            // 驱动 ID
    private String driverName;        // 驱动名称(要和 getDriverName() 一致)
    private String instanceName;      // 实例名称
    private String instanceDesc;      // 实例描述
    private boolean switchOn;        // 是否启用
    private Integer instanceStatus;   // 实例状态
    private List<DriverInstanceAttr> driverInstanceAttrs;  // 实例属性(配置项)
    private String projectId;         // 项目 ID
    private Long defaultSubsystemId;  // 默认子系统 ID
}
Point(点位)—— 你要关心的「每一项数据」

平台关注的最小数据单元,比如「空调当前温度」「灯的开关」。这是驱动和平台之间数据交互的核心载体。字段较多,按用途分组记:

java
@Data
public class Point {
    // —— 基础标识 ——
    private Long id;                   // 主键 ID
    private String pointName;        // 点位名称
    private String pointUniqueCode;    // 点位唯一码
    private String accessMode;        // 点位地址(如 Modbus 寄存器地址)
    private String dataType;          // 数据类型

    // —— 归属关系 ——
    private Long driverId;
    private Long driverInstanceId;
    private String driverInstanceName;
    private String driverName;
    private String projectId;

    // —— 运行时数据(上报时填)——
    private Object curValue;          // 当前值(上报时填这个)
    private Integer statusFlag;       // 质量码(正常数据设 1,见下方说明)

    // —— 物模型规格(spec)——
    private String dataSpecUnit;      // 单位标识
    private String dataSpecUnitName;  // 单位名称
    private Double optionMin;         // 取值下限
    private Double optionMax;         // 取值上限
    private Double dataSpecStep;      // 步长
    private String dataSpecZero;      // 关闭/0 值语义
    private String dataSpecOne;       // 开启/1 值语义

    // —— 其他 ——
    private Integer sort;             // 排序
    private String pointDescription;  // 描述
    private List<PointAttr> pointAttrs;  // 点位属性
}
上报数据的关键

调 pushPointValue() 前,一定要设好 curValue(当前值)和 statusFlag(质量码)。正常数据通常设 statusFlag = 1(参考各驱动 setStatusFlag(1) 的用法);异常时按驱动约定设置,让平台知道这个值能不能信。

物模型字段怎么用

做物模型类驱动(如 YCBox、MQTT 网关)时,optionMin/optionMax/dataSpecStep 定义取值范围和步长,dataSpecZero/dataSpecOne 定义开关语义,dataSpecUnit 定义单位——这些字段平台会用于前端控件生成和数据校验,按设备实际情况填写。

DriverApiResult(API 响应结果)—— HTTP 接口的「工作汇报」

HTTP 接口必须返回这个类型,包含成功标志、错误信息和数据。

java
@Data
@Accessors(chain = true)
public class DriverApiResult {
    private Boolean success = true;  // 是否成功
    private String errorMsg;        // 错误信息(success=false 时有效)
    private String msg;             // 提示信息
    private Object data;            // 返回数据
}
DriverInstanceAttr(实例属性)—— 配置项的「键值对」

每个驱动实例的配置(IP、端口、从站号等)都以 DriverInstanceAttr 列表形式挂载在 DriverInstance 上。第 6 章会讲怎么读取这些值。

java
@Data
public class DriverInstanceAttr {
    private String attrName;     // 属性名(如 "host"),对应SQL里的 attr_name
    private String attrValue;    // 属性值(如 "192.168.1.100")
    private Long instanceId;   // 所属实例 ID
}
PointAttr(点位属性)—— 点位的「协议参数」

每个点位除了通用字段(名称、地址、值),还有协议特有的属性——比如 Modbus 的功能码、BACnet 的对象类型。这些以 PointAttr 列表挂在 Point 上。

java
@Data
public class PointAttr {
    private String attrDisplayName;  // 显示名称(给前端用)
    private String attrName;        // 属性名(如 "functionCode")
    private String attrValue;       // 属性值(如 "3" = 读保持寄存器)
    private Integer attrSort;      // 排序
    private String dataType;        // 数据类型
    private String optionElementType; // 前端控件类型(input/select)
    // ... pointId、driverId、createTime 等
}
两类属性的区别

DriverInstanceAttr 描述实例级配置(整个实例一份,如连接 IP);PointAttr 描述点位级配置(每个点位一份,如寄存器地址)。它们的元数据(有哪些属性、什么类型)都在数据库初始化 SQL 里定义。

6
完整开发流程
从搭建到点位下发

前面几章是「零件」,这一章是「组装说明书」——把所有知识点串成一个完整的开发流程。日常工作中,每接一个新驱动,基本都走这套流程。

项目结构建议

驱动代码统一放在主工程的 drivers/inline/<驱动名>/ 包下。一个规范的驱动,建议按职责分包(参考现有驱动如 rcp、modbus):

tree
com.sctech.opti.cloud.drivers.inline.<driver-name>/
├── access/           # 驱动接入层(实现 DriverAccess,第 3 章讲的)
├── base/             # 基础管理类(连接管理、资源管理,如 XxxConnectorManager)
├── constant/         # 常量定义
├── entity/           # 实体类(驱动特定的数据结构)
├── task/             # 定时任务实现(第 4.2 节,如 AliveTask)
├── api/              # HTTP 接口实现(第 4.1 节,可选,如 ImportDriverInstanceApi)
├── util/             # 工具类
└── server/           # 服务端处理(连接、协议解析,如果是服务端模式)
开发环境搭建

前置条件:JDK 21 · Maven 3.6+ · 了解目标协议规范 · 准备测试设备或模拟器

关于依赖(重要)

驱动 SDK 是主工程内的包 com.sctech.opti.cloud.drivers.sdk,不需要在 pom.xml 里单独声明 SDK 依赖——你在主工程里写驱动代码,天然就能用所有 SDK 类。只有当你的驱动需要额外的协议库(比如 Netty、某个协议解析库)时,才往主工程 pom 加对应依赖。

协议库依赖示例(按需添加)
xml
<dependencies>
    <!-- 驱动 SDK 无需声明,已在主工程内 -->

    <!-- 协议相关依赖(按你的协议按需添加,例如 Netty) -->
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.x</version>
    </dependency>

    <!-- Lombok(必需) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
</dependencies>
实例生命周期管理(驱动的「生老病死」)

这是 DriverAccess 里生命周期类的方法。平台会在实例「打开/关闭/删除/更新」时调用它们,这是你管理连接、资源的地方。先看一张生命周期全景图,理解每个回调在什么时候触发:

新建
newDriverInstanceHasSaved
用户在前端创建实例
打开
openDriverInstance
启用实例 → 建连接、起轮询
运行中
(上报/下发正常工作)
心跳 + 数据上报 + 指令下发
关闭
closeDriverInstance
禁用实例 → 断连接、停轮询
删除
driverInstanceHasDeleted
彻底删除 → 清理所有资源
还有一条「更新」支线

运行中用户改了实例配置(如换了 IP),平台会调 driverInstanceHasUpdated,你在这里决定要不要重连。它不在主线上,但配置类驱动常需要处理。

下面以 RCP 驱动的真实实现为参考:

java
@Override
public boolean openDriverInstance(DriverInstance inst) {
    // 实例「开门营业」:建立连接、启动轮询
    return RcpConnectorManager.handleDriverInstanceOpen(inst);
}

@Override
public boolean closeDriverInstance(DriverInstance inst) {
    // 实例「打烊」:关闭连接、释放资源
    return RcpConnectorManager.handleDriverInstanceClose(inst.getInstanceId());
}

@Override
public boolean driverInstanceHasDeleted(Long instanceId) {
    // 实例被删除:彻底清理连接等资源
    return RcpConnectorManager.handleDriverInstanceHasDeleted(instanceId);
}

@Override
public boolean newDriverInstanceHasSaved(DriverInstance inst) {
    // 新实例刚保存:可做初始化(不需要则 return false)
    return false;
}

@Override
public boolean driverInstanceHasUpdated(DriverInstance inst) {
    // 实例配置被修改:可重新加载(不需要则 return false)
    return false;
}

@Override
public void issuePointValueBatch(List<Point> pointList) {
    // 平台下发指令:把点位值写到设备
    RcpConnectorManager.writeAllPointValue(pointList);
}
记忆要点

open 建、close 关、delete 清、issue 写,再加 saved/updated 两个变更通知。把这六个方法和「开关门 + 清理 + 下指令 + 通知」对应起来,就不会乱。不需要的回调返回 false 或空实现即可。

读取实例配置(开发最常用的操作)

这是新人写真实驱动时第一个会卡住的地方:用户在前端配的 IP、端口、从站号,驱动代码里到底怎么拿到?答案藏在 DriverInstance.driverInstanceAttrs 里——配置以「属性名→属性值」的键值对列表形式存在。

配置长什么样

用户在前端填的每一项配置,对应一个 DriverInstanceAttr,有 attrName(属性名,如 "host")和 attrValue(属性值,如 "192.168.1.100")。属性名必须和数据库初始化 SQL 里 attr_name 字段一致——这是驱动代码和前端配置对上号的桥梁。

标准写法(参考 Modbus 驱动真实代码):

java
// 在 openDriverInstance 等方法里,从实例上读取配置
@Override
public boolean openDriverInstance(DriverInstance inst) {
    List<DriverInstanceAttr> attrs = inst.getDriverInstanceAttrs();

    // 方式一:逐个按 attrName 取值(最常用)
    String host = attrs.stream()
        .filter(a -> a.getAttrName().equals("host"))
        .findFirst().map(DriverInstanceAttr::getAttrValue).orElse(null);
    String port = attrs.stream()
        .filter(a -> a.getAttrName().equals("port"))
        .findFirst().map(DriverInstanceAttr::getAttrValue).orElse(null);

    // 方式二:一次性转成 Map,后续直接 get(配置项多时更清爽)
    Map<String, String> cfg = attrs.stream()
        .collect(Collectors.toMap(
            DriverInstanceAttr::getAttrName,
            DriverInstanceAttr::getAttrValue));
    String slaveId = cfg.get("slaveId");

    // 拿到配置后,建立连接等业务逻辑
    return doConnect(host, Integer.parseInt(port), Integer.parseInt(slaveId));
}
易错点

配置项的 attrName(代码里取值用的 key)必须和数据库初始化 SQL 里定义的 attr_name 完全一致。比如 SQL 里写的是 'host',代码里却 equals("ip"),就读不到值——而且不会报错,只会得到 null。配置读不到时,第一时间核对这两处的命名。

属性验证(防止错误配置)

用户配置驱动实例或点位时,平台会调校验方法让你校验合法性——在这里抛异常,配置就保存不上去,能把错误挡在源头。校验方法分单条和批量两种:

java
// 单条校验:保存单个实例/点位时调用
@Override
public void validateDriverInstanceAttr(List<DriverInstanceAttr> attrList,
        Long instanceId, String projectId) throws Exception {
    // 校验实例属性,比如 IP 格式、端口范围等,不合法就抛异常
    RcpConnectorManager.validateDriverInstanceAttr(attrList, instanceId, projectId);
}

@Override
public void validateDriverPointAttr(List<PointAttr> pointAttrList,
        Long instanceId, Long pointId, String projectId) {
    // 校验点位属性,比如寄存器地址是否合法
}

// 批量校验:导入工程文件(含多个实例/点位)时调用,结果填入 ImportResult
@Override
public void validateDriverInstanceAttrBatch(List<ImportInstanceResultVo> instanceList,
        ImportResult<Object> res) {
    RcpConnectorManager.validateDriverInstanceAttrBatch(instanceList, res);
}

@Override
public void validateDriverPointAttrBatch(List<ImportPointResultVo.ImportPoint> pointList,
        ImportResult<Object> res, Long instanceId, String projectId) throws Exception {
    // 批量校验导入的点位
}
单条 vs 批量

界面手动增删改调单条校验(validateDriverInstanceAttr / validateDriverPointAttr);导入整份工程文件时调批量校验(带 Batch 后缀的方法),需要把每条的错误信息填入 ImportResult,让用户看到哪些行有问题。

7
部署上线
内嵌模式 · 上线流程
先说结论

当前版本只有内嵌模式可用——驱动代码编译进主工程 jar,随平台一起启动。DriverAccessMode.EXTERNAL(外置模式,愿景是 jar 上传加载)在框架里有枚举和预留骨架,但 DriverAccessLoader.watch() 方法标注 //todo 尚未实现,所以这一章只讲内嵌模式的上线流程。

内嵌模式上线流程
  1. 写代码:在 drivers/inline/<驱动名>/access/ 下实现 DriverAccess
  2. 登记 SPI:在 META-INF/services/com.sctech.opti.cloud.drivers.sdk.DriverAccess 文件里加一行你的实现类全路径
  3. 数据库初始化:编写并执行初始化 SQL,向 iot_driver / iot_driver_attribute / iot_driver_point_attribute 三张表插入驱动元数据(详见下方,这一步有门槛)
  4. 编译启动:mvn package 打包,启动平台后日志会打印「加载到驱动: xxx」即表示成功
第 3 步最容易被忽略

代码和 SPI 都对了,启动日志也显示驱动加载成功,但前端就是配不了实例、点位属性都是空的——99% 是忘了执行数据库初始化 SQL。驱动的属性元数据(实例有哪些配置项、点位有哪些协议参数)全部由 SQL 定义,不写 SQL 前端就没有配置界面。

数据库初始化 SQL(内置驱动的必需环节)

内置驱动上线,必须编写一段初始化 SQL,把驱动的元数据注册到数据库。这段 SQL 要往三张表插入数据:

表作用举例
iot_driver驱动主记录名称 bacnetip、版本、权限开关(允许谁建/改/删实例、设备、点位)
iot_driver_attribute实例属性元数据定义这个驱动有哪些实例配置项:host(IP)、port(端口)、slaveId(从站号)…
iot_driver_point_attribute点位属性元数据定义每个点位有哪些协议参数:寄存器地址、功能码、对象类型…
属性元数据 = 前端配置界面的"图纸"

你在 SQL 里定义的每个属性(attr_name、data_type、option_min/max、option_element_type 等),平台会据此自动生成前端的配置表单——是输入框还是下拉、必填还是选填、取值范围多少。所以 SQL 不仅是数据,更是 UI 的定义。这也正是未来声明式元数据要取代的东西(驱动用 SDK 声明,平台自动入库,不用手写 SQL)。

初始化 SQL 的标准结构(参考 BACnet 驱动真实 SQL)

因为一个驱动要为每个项目都插入一套记录,所以 SQL 通常用存储过程 + 游标遍历所有项目的方式,并做幂等保护(已存在则跳过)。骨架如下:

sql
-- 1. 遍历所有项目,为每个项目插入驱动记录
CREATE PROCEDURE proc_insert_xxx_driver()
BEGIN
    DECLARE v_project_id, v_driver_id ...;
    DECLARE cur CURSOR FOR SELECT id FROM `ibms-system`.sys_project;

    OPEN cur;
    read_loop: LOOP
        FETCH cur INTO v_project_id;

        -- 2. 幂等保护:该项目下已有此驱动则跳过
        IF NOT EXISTS (SELECT 1 FROM iot_driver
            WHERE driver_name='xxx' AND project_id=v_project_id) THEN

            -- 3. 插入 iot_driver(驱动主记录 + 权限开关)
            INSERT INTO iot_driver(driver_name, driver_version, ..., project_id)
            VALUES('xxx', 'v 1.0.0', ..., v_project_id);
            SET v_driver_id = LAST_INSERT_ID();  -- 拿到刚插入的driver_id

            -- 4. 插入 iot_driver_attribute(实例属性,如 host/port/slaveId)
            --    attr_name 要和代码里 getDriverInstanceAttrs 读取的 key 一致!
            INSERT INTO iot_driver_attribute
                (driver_id, attr_display_name, attr_name, data_type,
                 option_check_type, option_required, option_max, option_min, ...)
            VALUES
                (v_driver_id, 'IP地址',   'host',    'string', 'ip',   1, NULL, NULL, ...),
                (v_driver_id, '端口',     'port',    'int',    'port', 1, 65535, 1,    ...),
                (v_driver_id, '从站号',   'slaveId', 'int',    'int',  1, 247,   1,    ...);

            -- 5. 插入 iot_driver_point_attribute(点位属性,如寄存器地址、功能码)
            INSERT INTO iot_driver_point_attribute
                (attr_display_name, attr_name, data_type, driver_id, ...) VALUES (...);
        END IF;
    END LOOP;
    CLOSE cur;
END

-- 执行后清理
CALL proc_insert_xxx_driver();
DROP PROCEDURE IF EXISTS proc_insert_xxx_driver;
怎么写?照着抄

不要从零写——参考驱动设计文档各驱动目录下的初始化 SQL(如 BACnet/BACnet IP驱动_初始化SQL.sql),复制一份改成你的驱动名和属性即可。重点改三处:① driver_name(和 getDriverName() 一致);② 实例属性列表(你的驱动需要哪些配置);③ 点位属性列表(你的协议需要哪些参数)。

attr_name 命名——代码与数据库的"对暗号"

这是整个上线流程里最容易出错、且出错最难查的环节。三处命名必须完全一致:

SQL 定义
iot_driver_attribute
.attr_name = 'host'
代码读取
equals("host")
取配置值
前端展示
attr_display_name
="IP地址"

SQL 里的 attr_name、代码里 equals(...) 的字符串、前端显示用的是 attr_display_name——前两者必须字字相同,不一致则配置读不到值(且不报错,只返回 null)。改驱动时三处要同步修改。

内嵌模式的优势
  • 开发调试方便,IDE 里可直接断点
  • 部署简单,驱动随主应用一起发布
  • 性能好,驱动和平台同进程,无通信开销
  • 故障排查直接,日志在一起
内嵌模式的局限
  • 更新驱动需要重新打包、重启平台
  • 驱动 bug 理论上可能影响主进程(靠规范和异常处理规避)
  • 第三方驱动需并入主工程源码(外置模式实现后可解决)
关于外置模式(External)—— 设计预留,未实现

DriverAccessMode 枚举定义了 EXTERNAL("external", "外置模式"),设计愿景是让 SDK 独立成包、驱动打成 jar 上传平台加载(详见第 3 章设计理念),支持热加载、隔离故障、声明式元数据自动入库。框架的 DriverAccessLoader 里预留了 watch() 方法骨架(监听驱动目录),但目前是 //todo 状态,未实现。

给新人的提醒

如果你在旧文档或网上看到「外置 jar 热插拔」「配置 driver.external.path」「打包 jar 放目录自动加载」这类描述,那些都是规划中的能力,当前版本不可用。现在统一用内嵌模式开发。外置模式实现后本文档会同步更新。

如何确认驱动加载成功?

启动平台后看日志,DriverAccessLoader 会打印 加载到驱动: {name}, 版本: {version}, 接入方式:{内嵌模式}。如果看不到你的驱动,按FAQ Q2排查 SPI 文件。加载成功后,驱动的 init()、定时任务、HTTP 接口都会自动就绪。

8
最佳实践
日志 · 异常 · 并发 · 资源

这一节是踩坑总结。开发时未必每条都用得上,但出问题时回来翻,大概率能找到答案。

日志规范
级别什么时候用新人提示
DEBUG详细调试信息:参数值、中间状态排查问题时临时开,平时关
INFO重要运行信息:初始化完成、实例开关关键节点都要打,方便定位
WARN不影响运行但需注意的情况断线重试、配置异常等
ERROR影响功能正常使用的异常必须带完整堆栈
注意事项

① 不要在循环里打日志(会刷屏);② 异常日志要记录完整堆栈 log.error("xxx", e);③ 敏感信息(密码、密钥)绝不打印;④ 生产环境关闭 DEBUG。

异常处理原则
  1. 捕获后必须处理,不能吞掉
  2. 向上抛的异常要有明确含义
  3. 记录异常要带上下文信息
  4. HTTP 接口异常返回友好提示
资源管理要点
  1. 及时释放不用的资源(连接、句柄)
  2. 用 try-with-resources 自动关闭
  3. 在 closeDriverInstance 里清理
  4. 定期检查资源泄漏
并发处理
  1. 共享资源必须加锁保护
  2. 避免死锁,按固定顺序获取锁
  3. 用线程安全集合(如 ConcurrentHashMap)
  4. 合理使用平台线程池,避免任务堆积
java
// 连接管理器示例:用 ConcurrentHashMap 保证线程安全
// 注意:这是驱动的普通工具类,不要加 @Component 等 Spring 注解
// 驱动自己 new 出来用(如 static 实例),靠 SPI 加载而非 Spring 容器
public class ConnectionManager {
    private static final ConnectionManager INSTANCE = new ConnectionManager();
    public static ConnectionManager getInstance() { return INSTANCE; }

    private final ConcurrentHashMap<Long, Connection> connections = new ConcurrentHashMap<>();

    public Connection getConnection(Long instanceId) {
        return connections.get(instanceId);
    }

    public void removeConnection(Long instanceId) {
        Connection conn = connections.remove(instanceId);
        if (conn != null) conn.close();
    }
}
和「不用 Spring」矛盾吗?

不矛盾。驱动的管理类、工具类可以正常写(普通 Java 类 + Lombok),只是不能加 Spring 注解、不能让 Spring 容器管理。上面的 ConnectionManager 用静态单例(驱动自己控制生命周期),这是驱动内部类的正确写法——它不依赖 Spring,符合设计原则。

心跳上报
  • 驱动定期检查实例连接状态,通过 pushInstanceKeepAlive 上报,平台据此判断实例是否存活
  • 实例状态用 DriverInstanceStatus 枚举:RUNNING(1)运行中、CLOSED(0)已关闭、OFFLINE(3)离线(FAULT(2)已废弃)
  • 心跳间隔建议 4-10 秒(RCP 驱动用 0/4 * * * * ? 每 4 秒)

真实驱动(如 RCP)的心跳任务流程:先 getDriverData() 拿到所有实例 → 逐个检查连接 → 收集状态 → 批量推送:

java
@Override
public void execute() {
    log.debug("心跳检测开始");
    try {
        // 通过 PlatformClient 获取当前驱动的全部实例
        List<DriverData> driverDataList = platformClient.getDriverData();
        List<InstanceStatusToPush> statusList = new ArrayList<>();

        for (DriverData data : driverDataList) {
            for (DriverInstance inst : data.getInstances()) {
                // 检查该实例连接是否健康(各驱动自己实现)
                DriverInstanceStatus status = checkConnectionAlive(inst);
                InstanceStatusToPush s = new InstanceStatusToPush();
                s.setInstanceId(inst.getInstanceId());
                s.setStatus(status);
                statusList.add(s);
            }
        }
        if (!statusList.isEmpty()) {
            platformClient.pushInstanceKeepAlive(statusList);
        }
    } catch (Exception e) {
        log.error("心跳检测失败", e);
    }
}
关键点

不要自己维护一个「实例列表」——用 platformClient.getDriverData() 从平台获取,这样用户在前端新增/删除实例后,心跳任务能立刻感知到。各驱动只需实现 checkConnectionAlive 判断单个实例是否连得上。

9
常见问题 FAQ
遇到问题先来这里找

这一节收集了新人最常问的问题。开发卡住时,先来这里找答案,能省下大量问人和排查的时间。

Q1
驱动名称(getDriverName)有什么要求?为什么我的驱动加载了却用不了?

驱动名称必须与数据库中配置的名称完全一致(大小写敏感)。这是平台把「数据库配置」和「你的代码」对应起来的唯一线索。

命名规范:

  • 只能包含小写字母、数字、下划线
  • 不能用特殊字符和空格
  • 建议用有意义的英文名,如 modbus、rcp
  • 长度 ≤ 50 字符

排查思路:驱动日志显示加载成功,但实例打不开 → 99% 是这个名称和数据库对不上。

Q2
驱动启动后完全没反应,日志里也找不到我的驱动,怎么回事?

这是新人第一大坑,几乎每个人都踩过。按这个顺序排查:

  1. SPI 声明文件:META-INF/services/com.sctech.opti.cloud.drivers.sdk.DriverAccess 存不存在?文件名对不对?
  2. 文件内容:里面的实现类全路径对不对?有没有拼写错误?
  3. 类路径:实现类能不能被类加载器找到?
  4. 编译:代码有没有编译错误?target 目录里有没有生成对应的 class?

SPI 加载失败是静默的——不会报错,只是不加载。所以一定要逐项检查。

Q3
如何调试驱动?
  • 内嵌驱动:直接在 IDE 里打断点调试,最方便
  • 外置驱动:把日志级别调成 DEBUG 排查,或用 IDE 远程调试

调试技巧:

  1. 在关键位置加断点(init、openInstance、读写数据)
  2. 用 IDE 远程调试外置驱动
  3. 开启 DEBUG 日志看执行流程
  4. 用 Wireshark 抓包分析网络协议
Q4
Cron 表达式怎么写?我老是记不住。

不用记,记住几个常用的就够了(格式:秒 分 时 日 月 周):

  • 0/4 * * * * ? — 每 4 秒(心跳常用)
  • 0 0/10 * * * ? — 每 10 分钟
  • 0 0 * * * ? — 每小时整点
  • 0 0 12 * * ? — 每天 12 点

在 getCronExpression() 里返回对应字符串即可。crontab.guru 这类网站可以在线测试表达式。

Q5
点位数据怎么上报到平台?

用 platformClient.pushPointValue(List<Point>) 批量上报。

注意事项:

  1. 建议攒一批再上报,提高性能
  2. 上报前设好 curValue(当前值)和 statusFlag(质量码)
  3. 质量码 statusFlag:代码中正常数据通常设为 1(参考各驱动 setStatusFlag(1) 的用法),异常时按驱动约定设置
  4. 历史数据用 uploadHistoryData(List<HistoryPointValue>),告警用 pushAlarm(DriversAlarm)
  5. 避免过于频繁上报,可设上报间隔(如 1 秒)
Q6
驱动配置文件怎么管理?能读本地 properties 吗?

不建议用本地配置文件。驱动的配置应该通过驱动实例属性(DriverInstanceAttr)管理,由平台统一存储维护。

推荐做法:

  1. 不用本地配置文件
  2. 所有配置通过 DriverInstanceAttr 传递
  3. 在 validateDriverInstanceAttr 里校验合法性
  4. 配置变更通过 driverInstanceHasUpdated 通知驱动
Q7
设备断线了怎么自动重连?

采用「最大重试次数 + 重试间隔」的策略。关键:用循环而非递归(递归重试会导致调用栈堆积),且重试要在独立任务里做,不要阻塞心跳等调度线程:

java
public class ReconnectManager {
    private static final int MAX_RETRY = 10;
    private static final long RETRY_INTERVAL_MS = 5000; // 5秒

    // 用循环重试,避免递归导致栈堆积
    public boolean reconnect(Connection conn) {
        int retryCount = 0;
        while (retryCount < MAX_RETRY) {
            try {
                Thread.sleep(RETRY_INTERVAL_MS);
                conn.connect();
                log.info("重连成功");
                return true;
            } catch (Exception e) {
                retryCount++;
                log.warn("第 {} 次重连失败: {}", retryCount, e.getMessage());
            }
        }
        log.error("达到最大重连次数 {},放弃", MAX_RETRY);
        return false;
    }
}
两个反模式要避开

① 别用递归重试:catch 里再调 reconnect() 会让调用栈不断堆积,重试多次可能栈溢出,用 while 循环。② 别在心跳任务里同步重连:心跳任务每 4 秒跑一次,如果在这里 Thread.sleep 重连会阻塞调度线程。正确做法是把重连作为一次性异步任务提交到 platformClient.getDriverThreadPoolExecutor(),让它在独立线程跑,心跳任务只负责检测和触发。

Q8
如何保证点位数据的准确性?

数据校验措施:

  1. 读取后做范围校验(最小值、最大值)
  2. 检查数据类型是否匹配
  3. 验证数据质量码(statusFlag)
  4. 对异常数据过滤或标记
  5. 支持滤波处理(去抖动、平均值等)
恭喜,你已经读完整份指南! Lv.5 独立开发
从「不知道驱动是什么」到「能独立开发一个完整驱动」,你已经具备了全部理论知识。接下来最好的学习方式是——接一个真实的小驱动任务,从头到尾做一遍。遇到问题随时回这份指南查阅,也欢迎把新的踩坑经验补充到 FAQ 里。