BACnet IP 驱动功能设计
OptiCloud IoT 平台 — BACnet/IP 协议驱动功能设计文档
BACnet IP 协议完全指南
从零开始理解楼宇自控的"通用语言" · ANSI/ASHRAE 135 · ISO 16484-5
什么是 BACnet 协议
想象一栋现代化办公大楼,里面有成百上千台设备:空调、灯具、电梯、消防报警器、门禁……它们由不同厂家生产,各自"说着不同的语言"。如果要温度高于 28°C 时自动开空调,就需要一种统一的沟通规则,让所有设备能听懂彼此。
→ BACnet 就是这个"统一语言"——楼宇自控领域的"英语"。
BACnet(Building Automation and Control Networks,楼宇自动化和控制网络)是专为楼宇自控设计的开放标准通信协议。由 ASHRAE(美国采暖、制冷与空调工程师学会)制定,标准编号 ANSI/ASHRAE 135,同时也是 ISO 16484-5 国际标准。
"开放标准"意味着:任何人都可以免费使用,不受任何一家公司控制。不同厂家的设备只要遵循 BACnet 标准,就能直接互通,无需购买额外的协议转换器。
| 问题 | BACnet 的解决方案 |
|---|---|
| 不同厂家设备无法互通 | 统一数据模型和通信服务 |
| 系统集成成本高 | 兼容设备即插即用 |
| 缺乏标准化管理 | 统一描述、命名、告警 |
| 厂商锁定 | 开放标准,自由选型 |
什么是 BACnet IP
BACnet 支持"走不同线路"传输数据。就像信件内容格式相同,但可以通过快递公司、邮局或专人投递。BACnet/IP 是通过普通计算机网络传输的方式,是目前最主流的选择。
| 传输方式 | 线路类型 | 速度 | 特点 |
|---|---|---|---|
| BACnet/IP | 以太网/网络线 | 最快 | 直接利用现有 IP 网络,本驱动采用 |
| BACnet MSTP | RS-485 串行线 | 较慢 | 老旧设备改造场景 |
| BACnet PTP | RS-232 串行线 | 很慢 | 点对点拨号(已少用) |
BACnet IP 使用 UDP(User Datagram Protocol)通信。与 TCP("打电话"——先拨号接通再通话)不同,UDP 像"发短信"——直接发出去,无需事先建立连接,速度更快、开销更低。
为什么选 UDP?
① 实时性要求高,需要低延迟
② 设备资源有限,TCP 管理开销太大
③ BACnet 自身已有确认重传机制
默认端口 47808(十六进制 0xBAC0,取 BACnet 前三个字母)。
单播:一对一,直接向目标设备发消息。
广播:一对多,向网络中所有设备喊话(用于发现设备)。
| 传输层 | UDP 无连接("发短信") |
| 默认端口 | 47808(0xBAC0) |
| 发现机制 | Who-Is / I-Am 广播 |
| 通信方式 | 单播(日常)+ 广播(发现) |
192.168.1.10
端口 47808"] <--"UDP 数据包
通过以太网"--> DEV["BACnet 设备
192.168.1.100
端口 47808"] style DRV fill:#e8f0fe,stroke:#007aff,color:#1c1c1e style DEV fill:#e8f0fe,stroke:#007aff,color:#1c1c1e
BACnet 对象模型——设备数据的组织方式
把一台 BACnet 设备想象成一部智能手机。手机上有很多"App"(天气、音乐、设置),每个 App 展示不同的数据。在 BACnet 中,每个 "App" 就是一个对象(Object):
· 温度传感器这个"App" → Analog Input 对象
· 空调开关这个"App" → Binary Output 对象
每个对象有多个属性(Property)——就像 App 显示的各种信息(当前值、名称、单位等)。
设备身份信息"] AI1["Analog Input #1
一楼大堂温度
当前值: 25.6C"] AI2["Analog Input #2
二楼办公区温度
当前值: 26.3C"] BO1["Binary Output #1
空调机组启停
当前值: 运行"] AO1["Analog Output #1
新风阀门开度
当前值: 75%"] DEV --> DOBJ & AI1 & AI2 & BO1 & AO1 style DEV fill:#007aff,color:#fff style DOBJ fill:#5856d6,color:#fff style AI1 fill:#34c759,color:#fff style AI2 fill:#34c759,color:#fff style BO1 fill:#ff9500,color:#fff style AO1 fill:#ff9500,color:#fff
模拟量(Analog) = 连续变化的数值(如温度 23.5); 二元量(Binary) = 只有两个状态(开/关); 多态量(Multi-State) = 多个固定状态(如高/中/低三档)。
| 分组 | 对象类型 | 缩写 | 方向 | 通俗解释 | 生活例子 |
|---|---|---|---|---|---|
| 模拟量 | Analog Input | AI | 只读 | 从传感器读取的连续数值 | 温度计读数 25.6 |
| Analog Output | AO | 读写 | 控制外部设备的连续值 | 阀门开度设为 75% | |
| Analog Value | AV | 读写 | 设备内部参数/变量 | 温度设定点 24 | |
| 二元量 | Binary Input | BI | 只读 | 从设备读取的开/关状态 | 风机是否在运行 |
| Binary Output | BO | 读写 | 控制设备的开/关 | 开启/关闭风机 | |
| Binary Value | BV | 读写 | 内部的开/关标志 | 手动/自动模式标志 | |
| 多态量 | Multi-State Input | MSI | 只读 | 读取的多选一状态 | 阀门: 全关/半开/全开 |
| Multi-State Output | MSO | 读写 | 控制的多选一状态 | 风扇: 关/低/中/高 | |
| Multi-State Value | MSV | 读写 | 内部的多选一参数 | 模式: 制冷/制热/通风 |
| Input 输入 | 数据从外部"流入"设备 只能读取,不能修改 例:传感器数据天然是只读的 |
| Output 输出 | 数据从设备"流出"控制外部 可以读取,也可以修改 例:控制指令,既能看状态也能改状态 |
| Value 值 | 设备内部的参数/变量 可以读取,也可以修改 例:设定点、阈值等软件参数 |
| 属性 | 编号 | 通俗解释 |
|---|---|---|
Present Value | 85 | 当前值——最核心属性,点位数据就是它 |
Description | 28 | 对象的文字说明(通常用作点位名称) |
Units | 117 | 物理单位(如摄氏度、百分比) |
Min/Max Pres Value | 69/70 | 允许的最小值/最大值(量程范围) |
Status Flags | 111 | 状态标志(报警/故障/被覆盖/停用) |
Priority Array | 87 | 写入控制的优先级队列(见第 6 节) |
COV Increment | 22 | 变化多少才触发通知(见第 5 节) |
192.168.1.100:47808"] --> B["设备实例号
Device ID: 101"] B --> C["对象类型
analogInput"] C --> D["对象实例号
#1"] D --> E["属性
presentValue"] E --> F["结果: 25.6"] style A fill:#e8f0fe,stroke:#007aff,color:#1c1c1e style F fill:#e8f8e8,stroke:#34c759,color:#1c1c1e
BACnet 服务——设备之间怎么"对话"
"服务"就是设备之间互相通信的指令类型。就像餐厅点餐:
· ReadProperty = "这道菜是什么食材?"(查询信息)
· WriteProperty = "把这道菜做成不辣的"(修改信息)
· Who-Is = 前台喊"XX 号桌的客人在吗?"(广播找人)
· SubscribeCOV = "菜好了叫我"(订阅通知)
| 类别 | 服务 | 通俗解释 | 实际用途 |
|---|---|---|---|
| 数据 读取 | ReadProperty | "告诉我某个属性的值" | 读取某个温度传感器的当前温度 |
| ReadPropertyMultiple | "一次性告诉我多个值" | 一条请求同时读取 100 个传感器 | |
| 数据 写入 | WriteProperty | "把这个属性设为指定值" | 将空调设定温度改为 24 |
| 设备 发现 | Who-Is | "网络中的设备,谁在?" | 广播查找网络中所有设备 |
| I-Am | "我在!我是设备 X" | 设备回复自己的身份信息 | |
| COV 订阅 | SubscribeCOV | "值变了请通知我" | 订阅温度变化通知 |
| COVNotification | "你订阅的值变了" | 设备主动推送变化后的值 |
COV 订阅——"值变了主动告诉我"
驱动每隔 10 秒问设备:"温度是多少?"
设备答:"25.6"。
10 秒后再问:"温度是多少?"
设备答:"25.6"。
问题:大部分时候没变化,白白浪费带宽;且最多有 10 秒延迟。
驱动跟设备说:"帮我盯着温度,变化超过 0.5 就告诉我。"
设备答:"好的。"
温度从 25.6 变为 26.2(变化 0.6 大于 0.5):
设备主动推送:"温度变了,现在 26.2!"
优势:不浪费带宽,几乎零延迟。
| 对比维度 | COV 订阅 | 定时轮询 |
|---|---|---|
| 实时性 | 高——变化即推送 | 取决于轮询间隔 |
| 网络开销 | 低——仅变化时发送 | 高——持续占用带宽 |
| 可靠性 | 需处理订阅过期/丢失 | 简单可靠 |
| 适用场景 | 频繁变化的关键点位 | 所有场景(兜底方案) |
本驱动策略:COV + 轮询双模式——优先 COV,失败自动回退轮询。
写入优先级——谁的命令优先
同一个设备可能同时被多个系统控制:
· 消防系统:检测到火灾,紧急关闭新风 → 最高优先
· 定时策略:下班时间自动关空调 → 中等优先
· 运维人员:手动关闭某台设备 → 一般优先
如果同时下达不同指令,设备该听谁的?BACnet 用 16 级优先级解决:编号越小优先级越高。
6 是非空槽位中编号最小的"] PA --> RESULT FIRE["消防触发
槽位 1 = 关闭"] FIRE --> RESULT2["实际输出 = 槽位 1 = 关闭
消防优先级最高"] style S6 fill:#34c759,color:#fff style S8 fill:#ff9500,color:#fff style S12 fill:#8e8e93,color:#fff style RESULT fill:#e8f0fe,stroke:#007aff,color:#1c1c1e style FIRE fill:#ff3b30,color:#fff style RESULT2 fill:#ff3b30,color:#fff
本驱动默认使用优先级 8(Manual-Operator)——高于自动控制(9~16),低于紧急和消防(1~7)。
BACnet 协议特点总结
驱动整体架构
本驱动采用 一实例一设备 模型——每个驱动实例对应一台 BACnet 物理设备。
· 独立线程互不阻塞
· 配置变更互不干扰
· 与平台模型完全对齐
实例属性设计
| 属性标识 | 显示名称 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
host | 本机IP地址 | string | 必填 | 0.0.0.0 | 绑定所有网卡 |
port | 本机端口 | int | 必填 | 47808 | BACnet UDP 端口 |
localDeviceId | 本机设备ID | int | 必填 | 自动生成 | 网络内唯一 |
broadcastAddress | 广播地址 | string | 可选 | 255.255.255.255 | Who-Is 广播 |
timeout | 通信超时(ms) | int | 必填 | 6000 | 请求超时 |
retries | 重试次数 | int | 可选 | 2 | 失败重试 |
segmentTimeout | 分段超时(ms) | int | 可选 | 5000 | APDU 分段 |
segmentWindow | 分段窗口 | int | 可选 | 5 | 滑动窗口 |
maxReadMultipleReferences | 批量读取上限 | int | 可选 | 20 | 单次请求上限 |
deviceIp | 设备IP地址 | string | 可选 | - | 手动时用户填写,扫描时自动填充 |
devicePort | 设备端口 | int | 可选 | 47808 | 手动时用户填写,扫描时自动填充 |
deviceInstanceId | 设备实例号 | int | 可选 | - | 手动时用户填写,扫描时自动填充 |
syncInterval | 巡检周期 | int | 可选 | 60 | 对象变化巡检周期数(约5分钟) |
maxCovSubscriptions | COV订阅上限 | int | 可选 | 50 | 单实例最大COV订阅数,0=不限 |
点位属性设计
| 属性标识 | 显示名称 | 类型 | 必填 | 默认值 |
|---|---|---|---|---|
deviceIp | 设备IP地址 | string | 必填 | - |
devicePort | 设备端口 | int | 必填 | 47808 |
deviceInstanceId | 设备实例ID | int | 必填 | - |
objectType | 对象类型 | select | 必填 | - |
objectInstance | 对象实例号 | int | 必填 | - |
propertyIdentifier | 属性标识 | select | 必填 | 85(固定 presentValue) |
bacnetWriteType | BACnet写入类型 | select | 可选 | 自动探测 |
covSubscription | COV订阅 | select | 可选 | disabled |
covIncrement | COV变化增量 | float | 可选 | 0 |
数据类型不再仅依赖对象类型推断,而是通过读取设备实际返回的 presentValue 探测真实类型。部分设备的 analogOutput 返回 SignedInteger 而非 Real,靠实际值探测可精确匹配。
| BACnet Encodable 类型 | 平台 dataType | bacnetWriteType |
|---|---|---|
| Real / Double | float | Real / Double |
| UnsignedInteger / SignedInteger | int | UnsignedInteger / SignedInteger |
| Enumerated | enum | Enumerated |
| Boolean / BinaryPV | int(返回 0/1) | BinaryPV |
| CharacterString | string | CharacterString |
BinaryPV 返回整数 0/1 而非布尔值,兼容平台 DevicePointListener 处理逻辑。
通常为 float"] end subgraph binary["二元量 BI/BO/BV"] BD["dataType = int
返回 0/1"] end subgraph multi["多态量 MSI/MSO/MSV"] MD["dataType = enum"] end style analog fill:#e8f0fe,stroke:#007aff,color:#1c1c1e style binary fill:#ffe5e5,stroke:#ff3b30,color:#1c1c1e style multi fill:#fff8e1,stroke:#ff9500,color:#1c1c1e
(propertyIdentifier 固定85不参与)"] style U fill:#007aff,color:#fff style R fill:#34c759,color:#fff
自动发现与扫描流程
自动建模规则
localDeviceId=100000+deviceId"] INST --> OBJ["读取 Object List
过滤有效数据对象"] OBJ --> READ["批量读 objectName+presentValue
探测实际数据类型"] READ --> AMETA["预读模拟量元数据
units/min/max"] AMETA --> DT{"DeviceType
BACnet_id 存在?"} DT -->|No| NEW_DT["创建 DeviceType
命名: BACnet_{deviceInstanceId}
每对象一个 Property"] DT -->|Yes| REUSE_DT["复用 DeviceType"] NEW_DT --> DEV REUSE_DT --> DEV DEV["创建 1 个 Device
编码: deviceInstanceId
显示名: 设备描述"] DEV --> PTS["创建 N 个 Point
每对象1个Point(仅presentValue)
命名: objectName_{id}"] PTS --> FILL["填充 dataType/bacnetWriteType
+模拟量规格"] FILL --> BIND["自动绑点"] BIND --> DONE(["完成"]) style START fill:#007aff,color:#fff style DONE fill:#34c759,color:#fff style NEW_DT fill:#5856d6,color:#fff style DEV fill:#34c759,color:#fff style PTS fill:#ff9500,color:#fff
非空?"} B -->|No| D["使用降级名
objectType_objectInstance"] B -->|Yes| C["objectName_{deviceInstanceId}"] D --> E["拼接 _{deviceInstanceId}"] style C fill:#34c759,color:#fff style E fill:#ff9500,color:#fff
自动绑点规则
deviceInstanceId"] --> RES D[" && "] --> RES E["属性标识
objectType_objectInstance"] E --> RES style RES fill:#007aff,color:#fff
示例:12345 && 101 && analogInput_1 注意:此处属性标识为 DeviceTypeProperty 的 objectType_objectInstance,而非 BACnet 属性编号(点位本身固定读 presentValue=85)。
数据采集架构
批量读取"] RD --> PH["推送数值"] PH --> PT{"巡逻周期?"} PT -->|Yes| SC["轻量检测
仅读Object List"] SC --> CH{"有变化?"} CH -->|Yes| FS["执行增量同步"] CH -->|No| SL["休眠等待"] FS --> SL PT -->|No| SL SL --> LP end subgraph CV["COV 监听"] CS["COV 订阅"] --> DN["设备发送
COV通知"] DN --> HD["COV处理器"] HD --> PV["推送变化值"] end subgraph HB["心跳检测 每4秒"] HA["BacnetAliveTask"] --> DC["检测可达性"] DC --> DM{"状态变化?"} DM -->|"运行→离线"| MO["标记离线
暂停采集"] DM -->|"离线→运行"| MN["标记运行
恢复采集+重订阅COV"] DM -->|"无变化"| HA end style ST fill:#007aff,color:#fff style FS fill:#34c759,color:#fff style MO fill:#ff3b30,color:#fff style MN fill:#34c759,color:#fff
平台注册的定时任务只有 1 个(BacnetAliveTask,每 4 秒),其余均为线程内部循环或计数触发,减少平台调度负担。
唯一注册任务"] end BAT --> S1["实例状态可达性检测"] BAT --> S2["读线程存活守护"] BAT --> S3["COV 续订 (顺带)"] S1 --> BO["指数退避
离线设备: 4s→8s→16s→32s→60s"] S2 --> RT subgraph INST["读线程 per instance (while 循环)"] RT["轮询读取
每 pollInterval (默认 5s)"] RT --> SYNC["定期巡检
每 ~5 分钟"] end style PLAT fill:#e8f0fe,stroke:#007aff,color:#1c1c1e style INST fill:#e8f8f0,stroke:#34c759,color:#1c1c1e style BAT fill:#007aff,color:#fff style BO fill:#ff9500,color:#fff style RT fill:#34c759,color:#fff style SYNC fill:#5856d6,color:#fff
| # | 机制 | 周期 | 实现方式 |
|---|---|---|---|
| 1 | BacnetAliveTask | 每 4 秒 | 平台 ScheduleCallback |
| 2 | 读线程轮询 | 每 5 秒 | Thread while 循环 |
| 3 | COV 续订 | 心跳内顺带 | 心跳任务内执行 |
| 4 | 定期巡检 | 每 ~5 分钟 | 读线程计数器触发 |
| 5 | 指数退避 | 动态 4~60s | 心跳任务内时间判断 |
缓存?"} -->|Yes| RL["重新加载点位"] NR -->|No| SP RL --> SP["分离轮询/COV点位"] SP --> DL{"遍历
设备"} DL --> RD2["ReadPropertyMultiple"] RD2 --> OK{"读取成功?"} OK -->|Yes| CV2["值类型转换"] OK -->|No| ERR["记录错误"] CV2 --> COL["收集变更"] COL --> DL ERR --> DL DL -->|"完成"| HC{"有值
变更?"} HC -->|Yes| PB["pushPointValueBatch"] HC -->|No| PA PB --> PA{"巡逻
计数?"} PA -->|Yes| OL["读取Object List对比"] OL --> CHG{"检测到
变化?"} CHG -->|Yes| AS["自动增量同步
删/增/改"] CHG -->|No| WT AS --> WT["休眠等待"] PA -->|No| WT WT --> NR end style START fill:#007aff,color:#fff style AS fill:#34c759,color:#fff
单实例默认最多 50 个 COV 订阅(可通过 maxCovSubscriptions 配置,0=不限)。超出上限时,按优先级保留订阅,其余降级为轮询。
< 上限?"} COUNT -->|"Yes"| SUB["正常订阅"] COUNT -->|"No"| PRI["按优先级排序"] PRI --> CMP["模拟量 > confirmed > unconfirmed"] CMP --> KEEP["高优先级: 保留COV订阅"] CMP --> DROP["低优先级: 降级为轮询"] DROP --> LOG["记录降级日志"] KEEP --> DONE(["完成"]) SUB --> DONE LOG --> DONE style START fill:#007aff,color:#fff style DONE fill:#34c759,color:#fff style DROP fill:#ff9500,color:#fff style KEEP fill:#34c759,color:#fff
优先级:analogInput/Output/Value (+10) > confirmed (+5) > unconfirmed (+3)
允许后续重试"] ONE -->|No 设备错误| MEM["加入 failedPointKeys
后续轮询跳过"] RT --> VAL MEM --> SKIP["本轮跳过"] VAL --> PUSH["推送"] PUSH --> CLR["syncObjectList 时
清空 failedPointKeys"] style POLL fill:#007aff,color:#fff style FALL fill:#ff9500,color:#fff style MEM fill:#ff3b30,color:#fff style CLR fill:#34c759,color:#fff
BACnet4J 解码 bug(非设备永久故障)与设备真实错误分类处理,避免误跳过可恢复点位。
或批次满?"} TMR -->|Yes| PUSH["pushPointValueBatch"] TMR -->|No| WAIT["继续累积"] style PUSH fill:#007aff,color:#fff
命令下发流程
WriteProperty 是确认型服务,设备必须返回响应。驱动利用此特性实现写入结果反馈。
对象不可写"] ERR --> E2["UNKNOWN_OBJECT
对象不存在"] ERR --> E3["VALUE_OUT_OF_RANGE
值超出范围"] ERR --> E4["INCONSISTENT_VALUE
类型不匹配"] OK --> LOG_OK["记录日志"] TMO --> FB["反馈到平台"] E1 --> FB E2 --> FB E3 --> FB E4 --> FB style WRITE fill:#007aff,color:#fff style OK fill:#34c759,color:#fff style ERR fill:#ff3b30,color:#fff style TMO fill:#ff9500,color:#fff style FB fill:#5856d6,color:#fff
写入时按 bacnetWriteType 匹配设备期望类型。若仍收到 INVALID_DATA_TYPE / INCONSISTENT_VALUE,自动两阶段重试:
类型错误?"} R -->|No| OK["成功"] R -->|Yes| S1["策略1: 诊断读取设备当前值
用设备实际类型写入"] S1 --> OK2{"成功?"} OK2 -->|Yes| OK OK2 -->|No| S2["策略2: 依次尝试预定义备用类型
getAlternativeWriteTypes"] S2 --> OK3{"任一成功?"} OK3 -->|Yes| OK OK3 -->|No| FAIL["全部失败 反馈平台"] style W fill:#007aff,color:#fff style OK fill:#34c759,color:#fff style S1 fill:#5ac8fa,color:#fff style S2 fill:#ff9500,color:#fff style FAIL fill:#ff3b30,color:#fff
错误码统一转大写下划线比较,兼容 BACnet4J 不同版本的 toString 格式差异。
实例生命周期与状态机
绑定UDP端口"] CLD --> IHD["初始化InstanceHolder"] IHD --> LPT["加载点位按设备分组"] LPT --> SCV["订阅COV"] SCV --> SRT["启动读线程"] SRT --> SRUN["状态 → RUNNING"] end subgraph P3["③ 运行"] RUN["正常运行"] --> HB["心跳检测 每4秒"] HB --> CHK{"设备可达?"} CHK -->|Yes| RUN CHK -->|No| SOFF["状态 → OFFLINE"] SOFF --> RCV["设备恢复可达"] RCV --> RCV2["重订阅COV
重启读线程"] RCV2 --> SRUN2["状态 → RUNNING"] SRUN2 --> RUN RUN --> UPD["用户修改属性"] UPD --> CB3["driverInstanceHasUpdated"] CB3 --> REB["重建连接"] end subgraph P4["④ 关闭"] DIS["用户禁用 switchOn=false"] --> CB4["closeDriverInstance"] CB4 --> UNC["取消所有COV订阅"] UNC --> STT["停止读线程"] STT --> CLL["关闭LocalDevice
释放UDP端口"] CLL --> CLR["清除所有缓存"] CLR --> SCL["状态 → CLOSED"] end subgraph P5["⑤ 删除"] DEL["用户删除实例"] --> CB5["driverInstanceHasDeleted"] CB5 --> PRG["彻底清理所有资源"] end CCI --> EN SRUN --> RUN SCL --> DEL style CR fill:#007aff,color:#fff style EN fill:#34c759,color:#fff style DIS fill:#ff9500,color:#fff style DEL fill:#ff3b30,color:#fff style SOFF fill:#ff9500,color:#fff
实例"} IL --> SW{"switchOn?"} SW -->|"false"| EC["确保已关闭"] SW -->|"true"| INI{"已初始化?"} INI -->|No| OPI["openDriverInstance"] INI -->|Yes| CRT{"读线程
存活?"} CRT -->|No| RST["重启读线程"] CRT -->|Yes| CDV["检测可达性
Who-Is"] RST --> CDV CDV --> ANY{"有设备
可达?"} ANY -->|Yes| SR2["状态=RUNNING"] ANY -->|No| SO2["状态=OFFLINE"] SR2 --> PRV{"状态
变化?"} SO2 --> PRV PRV -->|"运行→离线"| PO["推送离线状态"] PRV -->|"离线→运行"| RC2["恢复逻辑
重订阅COV
触发同步"] PRV -->|"无变化"| NX PO --> NX["下一实例"] RC2 --> NX NX --> IL IL -->|"完成"| PB2["pushInstanceKeepAlive"] EC --> IL style HBS fill:#007aff,color:#fff style RC2 fill:#34c759,color:#fff
设备离线后,检测间隔按 min(4 x 2n-1, 60) 秒递增,避免频繁探测浪费资源。
等待4秒"] --> F2["失败2次
等待8秒"] F2 --> F3["失败3次
等待16秒"] F3 --> F4["失败4次
等待32秒"] F4 --> F5["失败5+次
等待60秒"] F5 --> RCV{"设备
恢复?"} RCV -->|"Yes"| RST["重置退避
立即恢复采集"] RCV -->|"No"| F5 style F1 fill:#ff9500,color:#fff style F5 fill:#ff3b30,color:#fff style RST fill:#34c759,color:#fff
| 连续失败次数 | 退避间隔 | 累计等待 |
|---|---|---|
| 1 | 4 秒 | 4 秒 |
| 2 | 8 秒 | 12 秒 |
| 3 | 16 秒 | 28 秒 |
| 4 | 32 秒 | 60 秒 |
| 5+ | 60 秒(封顶) | 每 60 秒一次 |
对象变化自动同步
全量同步"] --> DET["变化检测"] T2["批量创建后
全量同步"] --> DET T3["离线恢复后
增量同步"] --> DET T4["属性更新后
增量同步"] --> DET T5["定期巡逻
每~60个读取周期"] --> DET DET --> LGT["轻量检测仅读Object List"] LGT --> CHG{"有变化?"} CHG -->|Yes| FLS["完整增量同步"] CHG -->|No| SKP["跳过继续轮询"] style DET fill:#007aff,color:#fff style FLS fill:#34c759,color:#fff
objType_objInst → BacnetObject"] BM --> BP["构建平台对象映射
objType_objInst → Point"] BP --> DF["计算差集"] DF --> ADD["新增对象
设备有 平台没有"] DF --> REM["删除对象
平台有 设备没有"] DF --> COM["共有对象
逐个比较属性"] COM --> CMP["比较描述/单位
最小/最大/分辨率"] CMP --> MOD["修改对象
属性有差异"] ADD --> RPT["DeviceChangeReport"] REM --> RPT MOD --> RPT RPT --> HC{"有变化?"} HC -->|Yes| AA["直接执行增量同步
无需用户确认"] HC -->|No| END(["结束"]) style START fill:#007aff,color:#fff style AA fill:#34c759,color:#fff style ADD fill:#34c759,color:#fff style REM fill:#ff3b30,color:#fff style MOD fill:#ff9500,color:#fff
deletePoints"] RMP["移除DeviceType对应Property"] end P1 --> P2 subgraph P2["阶段2: 新增"] NDT["更新DeviceType BACnet_id
添加新对象Property"] NDV["Device保持1个(无需新增)"] NPT["为新对象创建Point
1对象=1Point(presentValue)
探测类型"] end P2 --> P3 subgraph P3["阶段3: 修改 detectModifiedObjects"] UPT["自动修正点位名称
objectName_{id}"] UDT["自动修正dataType
与设备实际值对齐"] UWT["自动修正bacnetWriteType"] UAN["刷新模拟量规格
units/min/max"] end P3 --> CLR["清空failedPointKeys
允许重新读取"] CLR --> REF["刷新点位缓存"] REF --> DONE(["完成"]) style START fill:#ff3b30,color:#fff style DONE fill:#34c759,color:#fff style P1 fill:#ffe5e5,stroke:#ff3b30,color:#1c1c1e style P2 fill:#e8f8f0,stroke:#34c759,color:#1c1c1e style P3 fill:#fff8e1,stroke:#ff9500,color:#1c1c1e
已有对象"} LOOP --> NM["点位名称变化?
→ 更新 objectName_id"] LOOP --> DT["dataType 不匹配?
→ 探测值更新"] LOOP --> WT["bacnetWriteType 变化?
→ 更新写入类型"] LOOP --> SP["模拟量规格变化?
→ 更新 units/min/max"] NM --> CHG["收集变更点位"] DT --> CHG WT --> CHG SP --> CHG CHG --> SAVE["updateBatchPoint"] style DET fill:#007aff,color:#fff style SAVE fill:#34c759,color:#fff style CHG fill:#ff9500,color:#fff
设备固件升级后对象实际类型可能变化,自动修正保证点位信息始终与设备行为一致。
Web API 设计
扫描网络设备"] DRV --> BA["POST batchCreateInstances
批量创建实例"] DRV --> IA["POST importInstance
单台设备导入"] SA --> R1["返回设备列表+缓存3分钟
快速模式/排除已加入/定向广播"] BA --> R2["projectId+deviceIds
1设备=1DT+1Device+N Point
自动绑点+探测类型"] IA --> R3["发现单台设备
IP+实例号定向导入"] style DRV fill:#5856d6,color:#fff style SA fill:#007aff,color:#fff style BA fill:#34c759,color:#fff style IA fill:#5ac8fa,color:#fff
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| driverId | Long | 必填 | 驱动 ID |
| subnet | String | 可选 | 子网地址 默认0.0.0.0(全网),自动算定向广播 |
| timeout | int | 可选 | 超时秒数 默认 100 |
| detailed | string | 可选 | true详细(默认)/false快速模式仅IP端口 |
| deviceInstanceRangeLow | int | 可选 | ID 范围下限 |
| deviceInstanceRangeHigh | int | 可选 | ID 范围上限 |
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| projectId | String | 必填 | 项目 ID |
| deviceIds | String | 必填 | 设备ID列表(逗号分隔) 如 101,102,103 |
| subsystemId | Long | 可选 | 子系统 ID |
driverId/driverName 由平台自动检测,设备信息从扫描缓存(3分钟)读取,无需重传 devices JSON。
devicePort
deviceInstanceId"] --> M2["创建实例"] M2 --> M3["启用实例"] M3 --> M4["自动连接设备
firstTimeSync
自动同步对象"] end subgraph S["方式二:扫描创建"] S1["调用 scanDevices 扫描
支持subnet子网过滤"] --> S2["选择设备"] S2 --> S3["调用 batchCreateInstances"] S3 --> S4["自动建模+绑点
自动同步对象"] end subgraph I["方式三:单台导入"] I1["填写 deviceIp
deviceInstanceId"] --> I2["调用 importInstance"] I2 --> I3["确认设备信息"] I3 --> I4["调用 batchCreateInstances
完成创建"] end style M4 fill:#34c759,color:#fff style S4 fill:#34c759,color:#fff style I4 fill:#5ac8fa,color:#fff
批量导入设计
objectType, objectInstance,
propertyIdentifier, pointName"] R1["192.168.1.100, 47808, 101,
analogInput, 1, presentValue, 房间温度"] R2["192.168.1.100, 47808, 101,
analogOutput, 1, presentValue, 风门位置"] R3["192.168.1.100, 47808, 101,
binaryInput, 1, presentValue, 风机状态"] HDR --> R1 HDR --> R2 HDR --> R3 style HDR fill:#007aff,color:#fff
格式校验"] VL --> UC["唯一性校验
同实例不重复"] FC --> RS{"校验结果"} UC --> RS RS -->|"有错误"| ER["立即返回错误
标记行号+原因"] RS -->|"全部通过"| GRP["按设备分组"] GRP --> LOOP{"遍历每台设备"} LOOP --> INS["创建驱动实例
端口自动递增"] INS --> DT["创建/复用设备类型"] DT --> DEV["创建设备"] DEV --> PTS["批量创建点位"] PTS --> LOG{"成功?"} LOG -->|"Yes"| SUCC["记录成功"] LOG -->|"No"| FAIL["记录失败原因
继续下一台"] SUCC --> LOOP FAIL --> LOOP LOOP -->|"完成"| RPT["返回导入结果
成功N台 失败M台"] RPT --> OK(["完成"]) style UP fill:#007aff,color:#fff style OK fill:#34c759,color:#fff style ER fill:#ff3b30,color:#fff style FAIL fill:#ff9500,color:#fff style RPT fill:#5856d6,color:#fff
| 错误场景 | 处理方式 |
|---|---|
| CSV 格式错误 | 立即返回错误,提示行号和字段 |
| 单台设备创建失败 | 跳过该设备,继续下一台(部分成功模式) |
| 端口冲突 | 自动分配可用端口(47808 + N) |
| 平台 API 调用失败 | 重试 1 次,仍失败则跳过 |
| 点位数量超限 | 校验阶段拦截,不执行创建 |
DriverAccess SDK 集成
驱动通过 Java SPI 机制注册到平台,在 META-INF/services/com.sctech.opti.cloud.drivers.sdk.DriverAccess 中声明实现类。平台启动时自动发现并加载驱动。
+ HttpApiCallBack"] REG --> READY["驱动就绪"] style SPI fill:#e8f0fe,stroke:#007aff,color:#1c1c1e style READY fill:#34c759,color:#fff
| 分类 | 方法 | 实现要点 |
|---|---|---|
| 基础 标识 | init() | 轻量初始化(BACnet4J 日志、常量),重操作延迟到 openDriverInstance |
getDriverName() | 返回 "bacnetip" | |
getDriverVersion() | 返回 "v 1.0.0" | |
getAccessMode() | 返回 EMBEDDED(内嵌模式,共享 JVM) | |
| 平台 通信 | receivePlatformClient(pc) | 保存为静态字段,全局访问 |
getUniqueCodeByPointFromDriver() | 返回 null,使用平台默认绑定规则 | |
| 扩展 点 | getScheduleCallbackList() | 返回 [BacnetAliveTask],每 4 秒执行 |
getHttpApiCallBackList() | 返回 [ScanDevicesApi, BatchCreateInstancesApi] | |
| 校验 | validateDriverInstanceAttr() | IP/端口/localDeviceId 唯一性校验 |
validateDriverInstanceAttrBatch() | 批量导入时的实例级校验 | |
validateDriverPointAttr() | 对象类型/实例号/属性标识校验 | |
validateDriverPointAttrBatch() | 批量导入时的点位级校验 | |
| 实例 生命周期 | newDriverInstanceHasSaved() | 缓存初始化,状态 CLOSED |
openDriverInstance() | 创建 LocalDevice、绑定 UDP、启动读线程 + COV | |
closeDriverInstance() | 取消 COV、停止读线程、释放端口 | |
driverInstanceHasUpdated() | 比较属性差异,必要时重建连接 | |
driverInstanceHasDeleted() | 彻底清理所有资源 | |
| 命令 | issuePointValueBatch() | 按设备分组、类型转换、WriteProperty 优先级 8 |
平台通信桥梁"] PC --> GDD["getDriverData()
心跳获取实例数据"] PC --> PPV["pushPointValueBatch()
推送采集值"] PC --> PIK["pushInstanceKeepAlive()
推送实例状态"] PC --> SDT["saveDeviceTypeBatch()
批量创建设备类型"] PC --> SDV["saveDeviceBatch()
批量创建设备"] PC --> SBP["saveBatchPoint()
批量创建点位"] PC --> BPF["bindingPointForDriverInstance()
自动绑点"] PC --> CDI["createDriverInstance()
CSV导入创建实例"] style PC fill:#007aff,color:#fff
异常处理与容错
超时/拒绝/畸形数据"] ERR --> PLAT["平台API异常
推送失败/超时"] ERR --> THRD["线程异常
读线程退出/虚拟线程拒绝"] ERR --> RES["资源异常
内存不足/端口占用"] ERR --> BAC["BACnet协议异常
Error/Reject/APDU过长"] COMM --> S1["重试 → 标记离线 → 指数退避"] PLAT --> S2["记录日志 → 丢弃批次 → 不重试"] THRD --> S3["心跳检测 → 自动重启线程"] RES --> S4["限制缓存大小 → 端口自动递增"] BAC --> S5["降级为轮询 → 跳过该点位"] style ERR fill:#ff3b30,color:#fff style S1 fill:#34c759,color:#fff style S2 fill:#ff9500,color:#fff style S3 fill:#34c759,color:#fff style S4 fill:#5856d6,color:#fff style S5 fill:#007aff,color:#fff
| 场景 | 策略 |
|---|---|
| 设备超时 | 按 retries 重试,失败标记离线 |
| 连接被拒绝 | 标记设备离线 |
| APDU 过长 | 自动切换分段传输 |
| BACnet4J 解码异常(readANY) | 不加入永久跳过,降级逐个单点读取 |
| ReadPropertyMultiple 批次失败 | 自动降级为逐个 ReadProperty,解码/设备错误分类 |
| 反复读取失败的点位 | 加入 failedPointKeys 跳过,巡检时清空允许重试 |
| WriteProperty INVALID_DATA_TYPE | 诊断读取设备类型+备用类型自动重试 |
| WriteProperty WRITE_ACCESS_DENIED | 捕获 ErrorAPDUException,中文错误反馈 |
| COV 订阅失败 | 降级为轮询模式 |
| UDP 端口占用 | 共享 LocalDevice(引用计数),占用时报错提示 |
| 场景 | 策略 |
|---|---|
| pushPointValueBatch 失败 | 丢弃当前批次,下次重新采集 |
| saveBatchPoint 失败 | 返回错误到调用方 |
| pushInstanceKeepAlive 失败 | WARN 日志,不影响采集 |
| 平台 API 超时 | 跟随平台超时设置 |
| 共享资源 | 并发场景 | 安全策略 |
|---|---|---|
| BacnetInstanceHolder 缓存 | 心跳线程写 vs 读线程读 | ConcurrentHashMap 读写不冲突 |
| 点位值缓存 | 读线程 + COV 线程并发写 | ConcurrentHashMap put 覆盖 |
| LocalDevice | 读线程 + COV 回调 + 命令下发 | 单实例单 LocalDevice,BACnet4J 内部同步 |
| PlatformClient 调用 | 多线程同时调用 | PlatformClient 本身线程安全 |
核心原则:每个实例独立线程和资源,实例之间完全隔离,一个实例的异常不影响其他实例。
性能调优参考
| 参数 | 默认值 | 推荐范围 | 调优建议 |
|---|---|---|---|
| pollInterval | 5000ms | 3000~10000 | 过短浪费带宽,过长延迟高 |
| maxReadMultiple | 20 | 10~50 | 取决于设备支持程度 |
| syncInterval | 60 周期 | 30~120 | 约 5 分钟,不宜过小 |
| maxCovSub | 50 | 20~100 | 取决于设备支持的订阅数 |
| timeout | 6000ms | 3000~15000 | 网络差时适当调大 |
| retries | 2 | 1~3 | 不宜过大,避免阻塞读线程 |
| 维度 | 单实例 | 100 实例 |
|---|---|---|
| 线程数 | 1 读线程 + 1 COV | ~200 虚拟线程 |
| 内存 | ~2MB | ~200MB |
| UDP 端口 | 1 个 | 100 个(47808 起递增) |
| 网络带宽 | 取决于点位数 | 线性增长 |