主题
bacnet — BACnet 楼宇自控(MS/TP 与 BACnet/IP)
模块名:bacnet
哪些平台能用
支持的平台只有两个:ESP32-S3 固件(LP3232 等 S3 机型)和软 PLC 模拟器(Windows / Linux)。
- 普通 ESP32 / STM32 机型:固件里没有
bacnet模块,脚本里调bacnet.creat()会直接报错。原因是协议栈占掉一大块程序空间,普通 ESP32 的 app 分区放不下。 - 软 PLC 模拟器:BACnet/IP 可以正常用,和真机行为一致。MS/TP 能跑但时序不保证,见下面那条。
- 上位机 DTU 配置:BACnet 子标签页只在支持的机型上出现 —— 上位机会根据搜索到的设备能力(
features.dtu.bacnet_supported)决定是否显示,不支持的机型看不到这一页,也就不会配了半天才发现固件不认。
拿不准手上的设备支不支持,在脚本里打一句 print(type(bacnet)),输出 table 才算支持。
模拟器上的 MS/TP 只能验逻辑,不能验兼容性
模拟器的 MS/TP 走 PC 串口(USB-RS485 转换器),做不到协议要求的 0.8ms 级帧界判定 —— Windows 上 FreeRTOS 的时钟是软件 tick,USB 转换器本身还有毫秒级的批处理延迟。
结果是:当从站被 BMS 读一般没问题(Treply_timeout 有 255ms 余量);参与令牌环(Tusage_timeout 只有 20ms)会偶发丢令牌,表现是环反复重建、吞吐掉一截。
所以:拿模拟器验点表和脚本逻辑没问题,但判断某个 BMS/网关的 MS/TP 兼容性必须用真机。模拟器上跑不通不代表真机跑不通,反之也一样。BACnet/IP 没有这个问题。
在模拟器上用 BACnet/IP 还有两件事要注意:
- 必须指定
ifname。PC 上通常挂着一堆虚拟网卡(VMware 的 VMnet1/VMnet8、Hyper-V 的 vEthernet、VPN 的 TAP),自动挑很容易挑到其中一张,BMS 在真实局域网里就发现不了这台设备。把ifname填成本机在局域网里的 IP,例如ifname='192.168.1.20'。 - 47808 端口可能被占。如果电脑上装了 Yabe、BACnet Explorer 之类的工具,它们会占着标准端口,
start()会返回 0。关掉它们,或者把port改成 47809。第一次运行 Windows 防火墙还会弹窗问是否允许,要放行。
把 PLC 接进楼宇自控系统(BMS)。一个节点同时扮演两种角色,先看这两张图,整个模块就是这么回事:
从站 —— BMS 来读取本机。您要做的只是「把哪个寄存器叫成哪个 BACnet 点」:
主站 —— 本机主动读取其他设备。您要做的只是「去谁那里、读哪个点、多久一次、放到本机哪个寄存器」:
对应的 API:
- 从站:用
add_object把 D/M 寄存器包装成 BACnet 对象(AI/AO/AV/BI/BO/BV/MSI/MSO/MSV),BMS 用 ReadProperty 或 ReadPropertyMultiple 就能采到,可写对象还能被 BMS 下发。 - 主站:用
add_poll按周期去读别的 BACnet 设备(温控器、水表、变频器),值自动落到 PLC 寄存器。
两组 API 可以在同一个节点上混用,一条链路既对上采集又对下轮询。
不想写脚本?
整套功能在上位机里点几下也能配出来,见本页 不写 Lua:在上位机里配。想直接抄现成的脚本,见 拿来就能跑的示例。
支持两种链路,creat 的 link 参数选:
'mstp'—— MS/TP(RS-485 令牌环),占用一个串口。'ip'—— BACnet/IP(Annex J,UDP 47808),走网口或 WiFi,不占串口。
两条链路同时只能开一条
协议栈内部是单实例的(一份 Device 对象表、一份 TSM)。第二个 node:start() 会直接返回 0,不会偷偷复用。要换链路就 close() 再 start()。
配置必须在 start() 之前做完
add_object / del_object / add_poll / del_poll 在节点已经启动时会直接返回 0,不会生效。要改点表就 close() → 改 → start()。
原因是对象表同时被三个任务访问(Lua 任务、BACnet 协议任务、PLC 扫描任务),在线增删会让协议任务读到已释放的内存;BACnet 语义上也不该在线改 Object_List。
函数总览
库级函数
| 函数 | 作用 |
|---|---|
bacnet.creat | 创建一个 BACnet 节点对象 |
对象方法
| 方法 | 作用 |
|---|---|
node:add_object | 从站:暴露一个对象给 BMS |
node:del_object | 从站:删除一个对象 |
node:add_poll | 主站:增加一个周期轮询点 |
node:del_poll | 主站:删除一个轮询点 |
node:start | 启动节点(占用串口) |
node:close | 停止节点并释放串口 |
node:is_online | 总线上 5 秒内是否收到过有效报文 |
node:read | 主站:同步读一个/一批点 |
node:read_prop | 主站:同步读指定属性号 |
node:write | 主站:写对方的 Present_Value |
node:release | 主站:释放对方某个优先级槽 |
node:who_is | 广播 Who-Is 并列出已发现设备 |
node:object_list | 读对方的 Object_List(枚举对方有哪些点) |
node:stat | 通讯质量统计 |
node:reg | 注册事件回调 |
目标地址怎么写
add_poll / read / write / release / object_list 的第一个参数是对方地址,写法取决于链路:
- MS/TP:数字,对方的 MS/TP MAC,
1~127 - BACnet/IP:字符串,对方 IP,如
'192.168.1.50'(端口统一用本机配的port)
lua
node:read(12, 'AI:1') -- MS/TP,12 号站
node:read('192.168.1.50', 'AI:1') -- BACnet/IP对象地址写法
所有 API 里的 addr 都是 "类型:实例号",可以再带一个属性号:
lua
"AV:1" -- Analog Value,实例 1,属性默认 Present_Value(85)
"AI:10.85" -- 显式写属性号 85
"MSV:3" -- Multi-state Value,实例 3| 缩写 | object_type | 含义 | 可写 |
|---|---|---|---|
AI | 0 | Analog Input,模拟采集量 | 否 |
AO | 1 | Analog Output,模拟输出 | 是 |
AV | 2 | Analog Value,模拟中间量 | 是 |
BI | 3 | Binary Input,开关采集量 | 否 |
BO | 4 | Binary Output,开关输出 | 是 |
BV | 5 | Binary Value,开关中间量 | 是 |
MSI | 13 | Multi-state Input,多状态采集 | 否 |
MSO | 14 | Multi-state Output,多状态输出 | 是 |
MSV | 19 | Multi-state Value,多状态中间量 | 是 |
AI/BI/MSI在标准里就是采集量。给它们配bacnet.READ_WRITE会被静默降级成只读,不会报错。要让 BMS 能写就用AO/AV/BO/BV/MSO/MSV。
常量
数据类型(add_object / add_poll 的 mode 参数)
| 常量 | 值 | 说明 |
|---|---|---|
bacnet.BITE | 0 | 位。绑定到 M/X/Y/S 线圈,配 BI/BO/BV |
bacnet.S16 | 1 | 16 位有符号,占 1 个 D 寄存器 |
bacnet.U16 | 2 | 16 位无符号,占 1 个 D 寄存器 |
bacnet.S32 | 3 | 32 位有符号,占 2 个 D 寄存器 |
bacnet.U32 | 4 | 32 位无符号,占 2 个 D 寄存器 |
bacnet.FLOAT | 5 | 单精度浮点,占 2 个 D 寄存器 |
bacnet.DOUBLE | 6 | 双精度浮点 |
bacnet.ENUM | 7 | 枚举,配 MSI/MSO/MSV |
读写属性
| 常量 | 值 | 说明 |
|---|---|---|
bacnet.READ | 0 | 只读 |
bacnet.READ_WRITE | 1 | 可写。从站侧带 16 级优先级;主站侧寄存器变化时自动回写 |
事件(传给 node:reg)
| 常量 | 说明 |
|---|---|
bacnet.WRITE | BMS 写了本机的可写对象 |
bacnet.ONLINE | 总线恢复通讯 |
bacnet.OFFLINE | 总线通讯中断(5 秒没收到有效报文) |
bacnet.ERROR | 通讯错误(超时 / CRC / 被拒) |
错误码
read / write 返回的 errno,以及 bacnet.ERROR 回调的第一个参数:
| 值 | 含义 |
|---|---|
0 | 成功 |
8501 | 超时,对方没应答 |
8502 | CRC 校验错,接线或波特率问题 |
8503 | 帧格式错 |
8504 | 串口未打开 / 节点没启动 |
8505 | 地址非法(MAC 不在 1~127,或 addr 写错) |
8506 | APDU 长度超限 |
8601 | 对方说没有这个对象 |
8602 | 对方说没有这个属性 |
8603 | 对方拒绝写入(只读属性) |
8604 | 值超范围 |
8605 | 数据类型不匹配 |
8606 | 服务被拒绝 |
8607 | 对方忙 |
bacnet.creat
lua
bacnet.creat(cfg) --> node | nil创建一个 BACnet 节点对象。只做配置,不占用串口,真正上总线是在 start。
参数
cfg 是一张配置表,缺省项用默认值:
| 键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
link | string | 'mstp' | 'mstp' = RS-485;'ip' = BACnet/IP |
role | string | 'auto' | 'slave' / 'master' / 'auto' |
device_instance | number | 32001 | Device 对象实例号,全网唯一,上限 4194302 |
apdu_timeout | number | 3000 | 主站请求超时(ms) |
retries | number | 3 | 主站重试次数 |
vendor_id | number | 0 | 厂商代码 |
device_name | string | — | Device 对象名,BMS 上显示的名字 |
description | string | — | Device 描述 |
location | string | — | 安装位置 |
vendor_name | string | — | 厂商名 |
model_name | string | — | 型号 |
link = 'mstp' 时额外用到:
| 键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
com | number | 2 | 串口号 1~3 |
baud | number | 76800 | 只接受 9600 / 19200 / 38400 / 57600 / 76800 / 115200 |
mac | number | 5 | 本机 MS/TP 地址 1~127(0 是广播,不能用) |
max_master | number | 127 | 令牌环里最大主站地址,和总线上其它设备保持一致 |
reply_delay_ms | number | 200 | Treply_delay,最大 255,超过会被截断 |
link = 'ip' 时额外用到:
| 键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
port | number | 47808 | 本机 UDP 端口。BACnet 标准端口是 47808(0xBAC0) |
ifname | string | 自动 | 用哪张网卡:'eth' 网口 / 'sta' WiFi 客户端 / 'ap' WiFi 热点。不填就按 网口 → WiFi客户端 → WiFi热点 挑第一张已经拿到 IP 的 |
ifname不支持 4G(PPP)。拨号出去的是运营商分配的地址,不是局域网,BACnet/IP 的定向广播在上面没有意义。
role 的含义:
'slave'—— MS/TP 下表示不参与令牌环:只被动应答 BMS,总线流量少一半,也不会去抢别人的令牌。纯采集场景推荐这个。BACnet/IP 没有令牌环,这个值只表示"不做主动轮询"。'master'—— MS/TP 下参与令牌环,拿到令牌才能主动发请求。'auto'—— 声明过add_poll就自动变成'master',否则等于'slave'。
MS/TP 纯从站不会应答 Who-Is,要在 BMS 里手工填地址
MS/TP 从站永远拿不到令牌,而 I-Am 是广播帧、只能在持有令牌时发出去,所以 role='slave' 的节点收得到 Who-Is 但答不出 I-Am,BMS 的"扫描/发现设备"扫不到它。这是 MS/TP 从站的固有限制,不是配置错了。
做法是在 BMS 里手工把这台设备加进去,填 MAC 地址(mac)和 设备实例号(device_instance),之后 ReadProperty / WriteProperty 全部正常。要让 BMS 能扫到就得用 role='master'(参与令牌环),或者改用 BACnet/IP —— IP 下从站也能正常回 I-Am。
MS/TP 主站只在空闲时应答,对方也得是令牌环成员
role='master' 的节点只在空闲状态才处理别人发来的请求。它自己在轮询、传令牌、找后继主站的时候收到请求会直接丢掉,由对方重试。所以拿一台"不参与令牌环、只管发请求"的工具去测本机的主站节点,会看到时通时不通 —— 这是标准行为。正规的 BMS 主站会应答本机的 Poll For Master、接令牌、也把令牌传回来,那种情况下每次请求都能收到应答。
返回值
| 类型 | 说明 |
|---|---|
| userdata | 节点对象。必须用全局或模块级变量接住,被 GC 回收会停机并释放串口 |
| nil | 参数体检不过:MS/TP 下 mac 不在 1~127,或 device_instance 超过 4194302 |
示例
lua
-- MS/TP 从站
node = bacnet.creat({
link = 'mstp',
role = 'slave',
com = 2,
baud = 76800,
mac = 5,
device_instance = 1001,
device_name = 'LPPLC-1F',
location = '一层配电间',
})lua
-- BACnet/IP,走网口
node = bacnet.creat({
link = 'ip',
role = 'slave',
ifname = 'eth',
port = 47808,
device_instance = 1001,
device_name = 'LPPLC-1F',
})node:add_object
lua
node:add_object(addr, mode, var, [access, name, opt]) --> 1 | 0从站角色:把一个 PLC 寄存器包装成 BACnet 对象暴露给 BMS。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | addr | string | 是 | — | 对象地址,如 "AV:1",见对象地址写法 |
| 2 | mode | number | 是 | — | 数据类型常量,如 bacnet.FLOAT |
| 3 | var | string / number / userdata | 是 | — | 绑定目标,见下 |
| 4 | access | number | 否 | bacnet.READ | bacnet.READ 或 bacnet.READ_WRITE |
| 5 | name | string | 否 | 自动生成 AI_1 | 对象名,BMS 点表里显示的名字 |
| 6 | opt | table | 否 | — | 见下 |
var 支持三种写法:
- 字符串:寄存器名,
"D100"/"M10"/"M8013"/"X0"/"Y0"/"S20"。X/Y 按八进制,出现数字 8 或 9 会解析失败。 - 数字:
mode是bacnet.BITE时当 M 地址,否则当 D 地址。 - userdata:
data.creat()建的共享变量。
opt 可选项:
| 键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
units | number | 95 | BACnet 工程单位码,95 = no-units。摄氏度 62、% 98、kWh 19 |
states | number | 0 | MSI/MSO/MSV 的 Number_Of_States,不配则上报 255 |
relinquish_default | number | 0 | 可写对象在 16 个优先级槽全为 NULL 时的回落值 |
返回值
| 值 | 说明 |
|---|---|
1 | 成功。同一个 addr 再调一次是覆盖,不是新增 |
0 | 失败:节点已启动 / addr 写错 / var 解析不了 / 点表满(上限 64 个) |
示例
lua
-- 只读:D100 里的单精度浮点温度,单位摄氏度
node:add_object('AI:1', bacnet.FLOAT, 'D100', bacnet.READ, '回风温度', { units = 62 })
-- 只读开关量:M10 的运行状态
node:add_object('BI:1', bacnet.BITE, 'M10', bacnet.READ, '风机运行')
-- 可写:BMS 下发的温度设定值,落到 D200
node:add_object('AO:1', bacnet.FLOAT, 'D200', bacnet.READ_WRITE, '温度设定',
{ units = 62, relinquish_default = 24.0 })
-- 三档风速,MSV + ENUM
node:add_object('MSV:1', bacnet.ENUM, 'D210', bacnet.READ_WRITE, '风速档位',
{ states = 3 })说明
- 上限 64 个对象。静态数组,协议任务里不做内存分配。
- 采集方向的值由 PLC 扫描周期刷进影子副本,BMS 读到的是一致快照。32 位和浮点是两个 16 位寄存器,直接在协议任务里读会撕裂成「低字新、高字旧」。
start()之后的第一个扫描周期之前,影子里是 0。这个窗口只有一个扫描周期。
node:del_object
lua
node:del_object(addr) --> 1 | 0删除一个对象。节点已启动时返回 0 且不生效。
参数
| # | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 1 | addr | string | 是 | 对象地址,"AI:1" 这种。类型缩写不区分大小写("ai:1" 同一台),属性号写了也没用 —— 对象是按「类型 + 实例号」定位的,"AI:1" 和 "AI:1.85" 删的是同一个对象 |
返回值
| 值 | 说明 |
|---|---|
1 | 删掉了 |
0 | 什么都没删:addr 写法不合法(类型缩写不在支持列表里、实例号超过 4194302)、这个对象本来就不存在、或者节点已经在运行 |
运行中不许改对象表是硬限制(add_object / del_object / add_poll / del_poll 同一套),要改就 close() → 改 → start()。删掉对象不会动它绑的那个寄存器,只是 BMS 再也读不到它。
示例
lua
node:close()
node:del_object('AI:3') -- 这个点不给了
node:start()node:add_poll
lua
node:add_poll(mac, addr, mode, var, [opt]) --> 1 | 0主站角色:按周期去读另一台 BACnet 设备的点,值自动落到本机寄存器。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | dest | number / string | 是 | — | 对方地址,见目标地址怎么写 |
| 2 | addr | string | 是 | — | 对方的对象地址,如 "AI:1" |
| 3 | mode | number | 是 | — | 数据类型常量 |
| 4 | var | string / number / userdata | 是 | — | 值存到哪,写法同 add_object |
| 5 | opt | table | 否 | — | 见下 |
opt 可选项:
| 键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
interval | number | 5000 | 轮询周期(ms),最小 100,小于 100 会被抬到 100 |
access | number | bacnet.READ | 配 bacnet.READ_WRITE 时,本机寄存器被改动会自动写给对方 |
priority | number | 16 | 回写用的优先级 1~16 |
fail_flag | string | — | 连续 3 次读失败就置位的线圈,如 "M400";恢复后自动复位 |
on_change | function | — | 值变化回调,签名 function(value, addr)。第一次读到值不触发 |
返回值
1 成功,0 失败(节点已启动 / 地址写错 / 点表满,上限 64 个)。
示例
lua
-- 读 12 号温控器的室温到 D400,5 秒一次,连续失败置 M400
node:add_poll(12, 'AI:1', bacnet.FLOAT, 'D400', {
interval = 5000,
fail_flag = 'M400',
on_change = function(value, addr)
print('温控器室温变化', addr, value)
end
})
-- 双向:本机 D410 改了就写给对方的 AO:1
node:add_poll(12, 'AO:1', bacnet.FLOAT, 'D410', {
interval = 2000,
access = bacnet.READ_WRITE,
priority = 10,
})说明
role是'auto'时,调用这个函数会把角色升级成'master'(MS/TP 下要参与令牌环才能主动发请求)。- 周期别设太小。MS/TP 是令牌环,几十个点全设 100ms 会把总线打满、Treply_delay 撑不住。
- 轮询点读回来的值也过一次影子副本,由 PLC 扫描周期统一落寄存器(
fail_flag同样如此)。所以从「总线上收到应答」到「D100变新值」最多晚一个扫描周期。这样做是因为 32 位和浮点是两次独立的 16 位写,在协议任务里直接写会和扫描撕裂。 - 主站方向用的是单点 ReadProperty,没有 ReadPropertyMultiple 批量合并。反过来 BMS 用 RPM 读取本机是支持的。
node:del_poll
lua
node:del_poll(dest, addr) --> 1 | 0删除一个轮询点。节点已启动时返回 0 且不生效。
参数
| # | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 1 | dest | number / string | 是 | 对方地址,写法与 add_poll 第一个参数一致(MS/TP 给 MAC 数字,BACnet/IP 给 IP 字符串)。它参与匹配:从 12 号站读回来的点和从 13 号站读回来的 AI:1 是两个点,删的时候也得带上对应的地址 |
| 2 | addr | string | 是 | "AI:1" 这种对象地址。属性号不参与匹配,"AI:1" 和 "AI:1.85" 指向同一个槽位 |
返回值
| 值 | 说明 |
|---|---|
1 | 删掉了 |
0 | 什么都没删:addr 写法不合法、这个点没登记过、或者节点已经在运行 |
一个「对方设备 + 对象类型 + 实例号」只有一个槽位
因为属性号不参与身份匹配,add_poll(12,'AI:1',...) 之后再来一句 add_poll(12,'AI:1.77',...),返回的也是 1,但它是把前一条覆盖了,不是两个点。确实要同时读同一个对象的两个属性,就把其中一个写成别的实例号(做不到就先读一个,另一个用 read_prop 单独取)。
地址写错时 add_poll 不会拦
add_poll 不校验 dest(MS/TP 的 1~127、IP 的合法性是链路层在读写时才查的),所以 '192.168.1.300' 这种打错的地址照样登记得进去、返回 1。现象是这条点永远读失败:每轮投一次 bacnet.ERROR(8505),连续 3 次后 fail_flag 置位。删掉重配就行,但别以为返回 1 就是配对了。
示例
lua
node:close()
node:del_poll(12, 'AI:1')
node:start()node:start
lua
node:start() --> 1 | 0启动节点。
参数
无。
MS/TP 做四件事:
com_claim占用串口。被com或pipe占着就直接失败,不会静默抢过来。- 把该串口切成 BACnet MS/TP 模式,参数固定 8N1 + 配置的波特率,并写入 flash(已经是这套参数就不重复写,省 NVS 磨损)。切换前的模式和参数会记下来,
close()或启动中途失败时原样还原 —— 用过 BACnet 之后这个口的 Modbus 不会莫名不通。 - 打开协议栈。
- 建一个独立的 FreeRTOS 任务跑 MS/TP 令牌环和接收状态机。
BACnet/IP 做三件事:
- 按
ifname挑一张已经拿到 IP 的网卡,读出本机 IP 和子网掩码,算出定向广播地址(IP | ~掩码,不是 255.255.255.255 —— 路由器一律不转发它,有些 BMS 也不认)。 - 建 UDP 套接字绑到
0.0.0.0:port,开SO_BROADCAST,设成非阻塞。 - 建协议任务,5ms 一轮收包。
BACnet/IP 和 MS/TP 主站启动后会立刻广播一次 I-Am,省得 BMS 等下一轮 Who-Is。MS/TP 纯从站(role='slave')不发——从站拿不到令牌,这条广播发不出去,还会一直堵在发送队列队头把后面的正常应答一起卡死,所以直接不发。从站要靠 BMS 手工填地址来接入,见 role 的说明。
返回值
1 成功,0 失败:
- 共同:已经有节点在跑 / 建任务失败
- MS/TP:串口号不在 1~3 / 波特率不在支持列表 / 串口被别人占用
- BACnet/IP:
ifname指定的网卡不存在或还没拿到 IP / UDP 端口被占用
BACnet/IP 要等网络连上再启动
网卡还没 IP 时 start() 如实返回 0,不会绑到 0.0.0.0 然后装作已经启动。脚本里可以失败后隔几秒重试:
lua
while node:start() ~= 1 do
print('BACnet/IP 启动失败,等网络...')
system.sleep(5000)
end说明
- 协议任务优先级 5、钉在 core 0,和 Lua、云服务任务同级。
- MS/TP 的时序质量取决于调用节奏,任务里 1ms 一轮。76800bps 下一个字节 130μs,1ms 一轮既不丢帧也不误判静默。BACnet/IP 没这个约束(lwIP 自己有收包缓冲),放到 5ms 省 CPU。
- 同一个对象重复
start()返回1(空操作,不会再起一个任务)。返回0的是「另起一个 BACnet 节点」—— 上面那条 warning 说的单实例指的是这个。 start()失败的具体原因只有日志里那一行(带step=编号:串口号超范围 / 这个机型没有这个口 / 波特率不是标准值 / 串口被占用 / 链路层开不起来 / 内存不够起不了任务)。脚本里start()只拿到0/1,现场排查要去翻日志。
role='slave' 的节点不会去读任何轮询点
主站轮询只在角色是 master 时才跑。所以 creat 里写死 role='slave'、之后又 add_poll 了几个点,add_poll 每句都返回 1,但一个点都不会被去读:不报错、不超时、fail_flag 也不会置位,绑的寄存器一直停在原值。
要主动采集就别写死 slave:留 role='auto'(声明过 add_poll 会自动升成 master)或者直接写 role='master'。只有纯从站(BMS 来读取本机)才该写 slave。
node:close
lua
node:close() --> 1停止节点。MS/TP 把串口的模式/参数还原成 start() 之前的样子并释放占用,BACnet/IP 关掉 UDP 套接字。
参数
无。
返回值
恒为 1。节点本来没在跑时它是空操作,不报错、也不返回 0。只有 start() 才会返回 0,所以「关了再起」的循环里不用判断 close() 的返回值。
close() 返回之后,点表才可以改(add_object / del_object / add_poll / del_poll 认的就是这个状态),绑的寄存器也不再被协议栈动了。
close() 会同步等协议任务自己退出,因为任务可能正卡在主站的一次同步请求里。等待上限按 apdu_timeout * (retries+1) 算出来(默认参数下约 25 秒,一般几毫秒就退了)。所以主站角色下 close() 有可能明显阻塞 Lua 线程,别放在扫描周期里反复调。
退出前会把主站侧用 1~15 优先级写成功过的点释放掉(写 NULL)。否则脚本重启后,老值会一直卡在对方的优先级槽里,谁也改不动。从没写成功过的点不会去释放;连续两次释放失败就整个放弃 —— 对方已经离线了,剩下的点每个再等一轮超时会把 Lua 线程堵很久。
这里释放的只是 add_poll 那些点
自动释放的范围是轮询点里用 <16 优先级写成功过的那些。脚本自己调 node:write 打出去的优先级槽不在自动释放的范围内 —— 那种点要自己在退出前调 node:release。
示例
lua
-- 换点表的标准动作
node:close()
node:del_poll(12, 'AI:1')
node:add_object('AV:9', bacnet.FLOAT, 'D300', bacnet.READ, '备用')
node:start() -- 注意:统计计数器从这里重新开始清零node:is_online
lua
node:is_online() --> bool收到过对方有效报文就算在线。节点没启动时固定返回 false。判离线的时间窗两条链路不一样:
- MS/TP:5 秒。总线上一直有令牌帧在转,5 秒没听见任何有效帧就是真断了。
- BACnet/IP:60 秒。IP 没有"总线活着"这种旁证,只能靠收到给本机的报文;BMS 轮询周期常见 10~30 秒,用 5 秒会一直在 ONLINE/OFFLINE 之间抖。
参数
无。
返回值
布尔 true / false(不是 1/0)。判据就一条:stat().last_rx_ms 小不小于上面那个窗口 —— 它衡量的是「最近听到有效报文没有」,不是「本机能不能发出去请求」。节点没 start()(或已经 close())时恒为 false。
MS/TP 下这个值证明的是「总线活着」,不是「BMS 读取过本机」
MS/TP 的判据把别人之间的令牌帧、轮询帧也算数(只要 CRC 校验过),所以总线上有任何动静都会是 true —— 哪怕还没有一台 BMS 来读取过本机。想确认「有人真的读取过本机」,看 stat().tx_frames 会不会涨(纯从站只在应答时才发帧);被读这一侧没有事件可收,bacnet.WRITE 报的是被写的点。
BACnet/IP 没有这种旁证,只有发给本机的报文才算,所以 IP 下 true 才近似等于「有人在与本机通信」。
示例
lua
timer.start(1000, function()
-- 通讯断了就点亮故障灯,恢复后自动灭
plc.m(500, not node:is_online())
end)node:read
lua
node:read(mac, addr, [count]) --> { errno = 0, data = { v1, ... } }
node:read(mac, {addr1, addr2, ...}) --> { errno = 0, data = { v1, v2, ... } }主站同步读。第二个参数传数组时按批量读。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | dest | number / string | 是 | — | 对方地址,见目标地址怎么写 |
| 2 | addr | string / string 数组 | 是 | — | "AI:1" 这种对象地址;传数组就是连着读多个对象 |
| 3 | count | number | 否 | 1 | 只在第 2 个参数是字符串时有效:从 addr 的实例号开始连着读几个实例。read(12,'AI:1',3) 读的是 AI:1、AI:2、AI:3,不是同一个对象里的三个值 |
count 没有上限,给多少就发多少次串行往返:MS/TP 上 count=100 意味着最长可能阻塞到 100 × apdu_timeout × (retries+1)。要读一批不连续的点,用数组那种写法,看得清读了哪些。
会阻塞调用它的 Lua 任务
MS/TP 下最坏要等数秒(等令牌 + 超时 + 重试)。生产脚本请用 add_poll,read 只适合调试和一次性读取。
返回值
一张表:errno 是第一个出错的错误码(全部成功为 0),data 是读到的值数组。批量读时部分失败的位置填 0。
两种写法在失败时的形状不一样,取值前先判 errno:
| 情况 | errno | data |
|---|---|---|
| 全部读到 | 0 | 每个位置一个数(都折成了 double,位/枚举/多状态也在内) |
数组里某个位置不是字符串 / addr 写法不合法 | 8505 | 数组写法:那个位置是 0。单个地址写法:整张 data 是空表,不是 {0} |
节点没 start(),或链路没开 | 8504 | 同上按写法分 |
dest 不合法(MS/TP 不在 1~127、IP 串里有超过 255 的段) | 8505 | 同上 |
| 对方说没有这个对象 / 没有这个属性 | 8601 / 8602 | 失败位置 0 |
| 对方回的是字符串型属性(如 Object_Name) | 8605 | 同上 —— 这条读路只认能折成数值的内容 |
| 超时,对方没应答 | 8501 | 同上 |
addr 里带了属性号("AI:1.77")就按那个属性读,不带就是 Present_Value。要读的属性是文本时拿不到字符串,见上表的 8605。
示例
lua
local r = node:read(12, 'AI:1')
if r.errno == 0 then print('室温', r.data[1]) end
local r = node:read(12, { 'AI:1', 'AI:2', 'AV:5' })
-- BACnet/IP 链路,地址写字符串
local r = node:read('192.168.1.50', 'AI:1')传数组时是逐个发单点 ReadProperty,不是一帧 ReadPropertyMultiple。所以 N 个地址就是 N 次往返,别一次列几十个。
node:read_prop
lua
node:read_prop(dest, addr, prop_id) --> { errno = 0, data = { value } }读指定属性号,addr 里带的属性号会被忽略。常用属性号:
| 属性号 | 名称 |
|---|---|
| 77 | Object_Name |
| 85 | Present_Value |
| 87 | Priority_Array |
| 104 | Relinquish_Default |
| 117 | Units |
参数
| # | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 1 | dest | number / string | 是 | 对方地址,见目标地址怎么写 |
| 2 | addr | string | 是 | "AI:1" 这种「类型:实例号」。这一串里的属性号不参与本次请求(写了也会被第三个参数盖掉),只用来定位对象 |
| 3 | prop_id | number | 是 | 属性号,整数。不填直接抛 Lua 错误。号码合不合法不校验,对方不认就回 8602 |
返回值
{ errno = ..., data = ... }:
| 情况 | errno | data |
|---|---|---|
| 读到 | 0 | { 值 },只有一个元素,已折成 double |
| 没读到(地址不合法、链路没开、对方说没有、值折不成数值、超时) | 见错误码 | 空表 {} —— 和 read 失败位置填 0 的写法不一样,判成功只判 errno,取值写 r.data[1] |
能读出来的只有数值型属性:Real / Double / 整数 / 枚举 / 布尔都能拿到。77 Object_Name 这类文本属性读不出来(对方正常应答了,但本机侧只能折成数值,最终回 8605)。想拿对方的点名,用 object_list 或干脆在 BMS 的点表里抄。
示例
lua
-- 这个点是什么工程单位(Units = 117)
local r = node:read_prop(12, 'AI:1', 117)
if r.errno == 0 then
print('单位码', r.data[1])
else
print('读不到', r.errno)
end
-- 对方的 Relinquish_Default(104)
print(node:read_prop('192.168.1.50', 'AO:1', 104).data[1])同步调用,和 read 一样会阻塞,一次请求一个属性号。
node:write
lua
node:write(dest, addr, value, [priority]) --> errno主站写对方的 Present_Value。value 可以是数字或 bool。priority 1~16,默认 16(最低),越界会被改成 16。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | dest | number / string | 是 | — | 对方地址 |
| 2 | addr | string | 是 | — | "AO:1" 这种。发出去的 BACnet 数据类型由这里的对象类型缩写决定,不需要也别指望有个 mode 参数 |
| 3 | value | number / boolean | 是 | — | 布尔按 1/0 处理。其它类型抛 Lua 错误 |
| 4 | priority | number | 否 | 16 | 写进对方哪一个优先级槽。0 或超出 1~16 的值一律被改成 16,不会报错 |
按对象类型发出去的数据类型:
addr 的类型 | 实际发出的 BACnet 类型 | value 怎么用 |
|---|---|---|
BI / BO / BV | Enumerated | 0 → INACTIVE,非 0 → ACTIVE |
MSI / MSO / MSV | Unsigned Int | 小于 1 的值会被抬成 1 再发出去 |
其余(AI / AO / AV / DEV) | Real(单精度浮点) | 原样,经 float 中转 |
给 MSO/MSV 写 0,发出去的是 1
多状态值的编号从 1 起,所以「0」在这类对象上没有对应写法 —— 链路层会把任何小于 1 的值抬成 1。要把这个槽让出来,用 release 而不是写 0。
精度只到单精度
AI / AO / AV 这一档一律按 Real(单精度浮点)发出去 —— write 没有 mode 参数,点表里那套 bacnet.S32 / bacnet.DOUBLE 只管采集方向怎么把值放进寄存器,管不到写出去的报文。绝对值超过 1600 万左右的整数、小数点后第七位起都会丢。确实要写精确整数,只能落在走 Enumerated / Unsigned 的两类对象上(BI/BO/BV、MSI/MSO/MSV)。
返回错误码,0 表示对方接受了。
返回值
| 值 | 什么时候 |
|---|---|
0 | 对方回了 ACK,值已经进它那个优先级槽 |
8505 | addr 写法不合法(报文根本没发出去),或 dest 不合法 |
8504 | 节点没 start() / 链路没开 |
8501 | 超时,对方没应答(MS/TP 下还要算上等令牌的时间) |
8601 / 8602 | 对方说没有这个对象 / 没有这个属性 |
8603 / 8604 / 8605 / 8606 | 对方拒绝:只读、超范围、类型不对、服务不让用 |
8607 | 对方忙,或者本机的发送槽位已经用满 |
示例
lua
local err = node:write(12, 'AO:1', 25.5, 16)
if err ~= 0 then print('写失败', err) end
-- 用优先级 8 去压住别人的 16 号槽
node:write(12, 'AO:1', 25.5, 8)
node:write('192.168.1.50', 'BO:2', true) -- 开关量:true 发 ACTIVE
node:write(12, 'MSV:3', 2) -- 多状态:第 2 档写完要记得交还控制权:priority 小于 16 的写会一直占着对方那个槽,见 release。
node:release
lua
node:release(dest, addr, [priority]) --> errno往对方的指定优先级槽写 NULL,把控制权交还给更低优先级或 Relinquish_Default。
优先级机制:每个可写对象有 16 个槽,编号越小优先级越高。当前生效值 = 最高优先级的非 NULL 槽;全 NULL 时用 relinquish_default。所以「不再控制」不是写 0,而是 release。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | dest | number / string | 是 | — | 对方地址 |
| 2 | addr | string | 是 | — | "AO:1" 这种。「写哪个槽」由 priority 决定,addr 只负责定位对象;带了属性号就按那个属性释放,默认 Present_Value |
| 3 | priority | number | 否 | 16 | 要释放的那一个槽,1~16。0 或越界一律被改成 16 —— 也就是说这句是「释放 16 号槽」,不是「释放所有槽」 |
返回值
一个错误码,取值和 write 那张表完全一样(write 和 release 走的是同一条请求路,差别只在值的类型是 NULL)。0 表示对方把那个槽清空了。
一次只释放一个槽。当初用几个优先级写过,就要 release 几次。
这三种情况不会被自动释放
close() 只回收「add_poll 配了 bacnet.READ_WRITE、且真的用 <16 优先级写成功过」的那些点。下面这些都要脚本自己收尾:
- 直接调
node:write(...)打出去的槽(不管priority传的是几,手动写的一律不归自动释放管) add_poll配了回写、但priority用的是默认值 16 的点 —— 自动释放只认<16的槽,用 16 号槽写进去的值不会被收回去- 脚本
return/ 重启之前 —— 停机不会替您发 release
示例
lua
-- 用过 8 号槽,交还出去
local err = node:release(12, 'AO:1', 8)
if err ~= 0 then print('释放失败', err) end
-- 脚本正常收尾时把自己写过的点都还掉
node:release(12, 'AO:1', 8)
node:release('192.168.1.50', 'BO:2', 5)
node:close()同步调用,会阻塞;对方不在线时这句要等满一轮超时才返回。
node:who_is
lua
-- MS/TP
node:who_is() --> { { device = 实例号, mac = 地址 }, ... }
-- BACnet/IP
node:who_is() --> { { device = 实例号, ip = "192.168.1.50" }, ... }广播 Who-Is,然后返回当前已发现的设备表。最多列 64 台。
Who-Is 是异步的:I-Am 应答要等对方拿到令牌(MS/TP)或经过网络往返(IP)才回来。第一次调用通常是空表,隔一两秒再调一次才有内容。
lua
node:who_is() -- 先广播
system.sleep(2000)
for _, d in ipairs(node:who_is()) do
print('device', d.device, 'mac/ip', d.mac or d.ip)
endnode:object_list
lua
node:object_list(dest, [device_instance, max])
--> { errno = 0, data = { { type = "AI", type_id = 0, instance = 1 }, ... } }读对方 Device 对象的 Object_List,也就是「这台设备有哪些点」。
参数
| # | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|---|
| 1 | dest | number / string | 是 | — | 对方地址 |
| 2 | device_instance | number | 否 | 自动反查 | 对方的 Device 实例号 |
| 3 | max | number | 否 | 64 | 最多读几个,上限 256 |
device_instance 不传的话会去地址绑定表里按地址反查 —— 那张表是靠收到的 I-Am 填的,所以要先 who_is() 过,否则返回 errno = 8505(地址非法)。
这是一串同步请求,会阻塞很久
先读一次数组长度,再按下标逐个读,N 个对象就是 N+1 次往返。MS/TP 上读 30 个点可能要十几秒。只在调试/配置阶段调一次,别放进周期任务。
中途某一项失败时不会整体放弃:已经读到的照样返回,errno 记下第一个错误码。
示例
lua
node:who_is()
system.sleep(2000)
local r = node:object_list(12)
print('errno', r.errno, '读到', #r.data, '个对象')
for _, o in ipairs(r.data) do
print(o.type .. ':' .. o.instance) -- 例如 AI:1
endnode:stat
lua
node:stat() --> table通讯质量统计。
| 键 | 类型 | 说明 |
|---|---|---|
rx_frames | number | MS/TP:收到的有效帧数;IP:收到的 UDP 报文数 |
tx_frames | number | 发出的帧数 / 报文数 |
crc_errors | number | MS/TP:CRC 错误帧数,持续增长说明接线/终端电阻/波特率有问题。IP 下没有 CRC 这回事,这个位置给的是被丢弃的报文数(BVLC 解析失败、来自本机、广播里夹带 Confirmed 请求) |
timeouts | number | 主站请求超时次数 |
last_rx_ms | number | 距上次收到有效报文多少毫秒 |
max_reply_ms | number | 历史最大应答耗时(ms),见下 |
ip | string | 本机 IP。只有 BACnet/IP 链路才有这一项,MS/TP 下这个键不存在 |
max_reply_ms 量的是「收到报文 → 处理完并把应答交给链路层」这一段,也就是应用层自己占掉的 Treply_delay 预算。轮询节奏还会再贡献最多 1ms。MS/TP 下这个值接近配置的 reply_delay_ms 就说明快撑不住了;BACnet/IP 下没有这种硬约束,只当性能观察用。
node:reg
lua
node:reg(event, callback)注册事件回调。回调在 Lua 任务里执行(协议任务投消息过来),所以里面可以放心调其它 Lua API。
| 事件 | 回调签名 |
|---|---|
bacnet.WRITE | function(addr, value, priority, effective) |
bacnet.ONLINE | function() |
bacnet.OFFLINE | function() |
bacnet.ERROR | function(code, addr) |
bacnet.WRITE 的 effective 表示这次写入是否成为了当前生效值 —— BMS 用优先级 10 写、而优先级 5 的槽还占着时,effective 是 false。
参数
| # | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| 1 | event | number | 是 | bacnet.WRITE / bacnet.ONLINE / bacnet.OFFLINE / bacnet.ERROR 四个之一 |
| 2 | callback | function | 是 | 不是函数直接抛 Lua 错误 |
返回值
无(不返回任何东西,也没有反注册的接口)。同一个事件再 reg 一次就是覆盖,前一个函数从此不再被调用。
传这四个之外的值不报错,但也永远不会被调用 —— 轮询点的值变化只走 add_poll 的 on_change,reg 收不到它。
几条回调里会踩到的实情:
WRITE的第二个参数是「当前生效值」,不是 BMS 刚写进来的那个数。两个不一样恰好就是effective == false的那种情况(被更高优先级的槽压住了),别拿它当"对方下发的原值"用。- 只读对象被写不会触发
WRITE:add_object时是bacnet.READ的对象(含被静默降级的AI/BI/MSI),协议栈直接回绝,事件也不投。收不到事件不等于 BMS 没试。 ERROR是每一次失败投一条,不是攒到连续 3 次才投(3 次那个门槛只管fail_flag)。主站角色下对方一旦掉线,每个点每个周期都会投一条 —— 回调里别打日志打成刷屏,做去重或者只置标志。- 回调里再调
read/write这类同步 API 是允许的(它们就跑在 Lua 任务里),但会把协议任务挡到请求结束,MS/TP 上别在回调里连着发一串同步请求。
示例
lua
node:reg(bacnet.WRITE, function(addr, value, priority, effective)
print('BMS 写入', addr, value, '优先级', priority, '生效', effective)
end)
node:reg(bacnet.OFFLINE, function()
plc.m(500, true) -- 点亮通讯故障灯
end)
node:reg(bacnet.ERROR, function(code, addr)
print('BACnet 错误', code, addr)
end)拿来就能跑的示例
下面三段是完整程序,整段复制进 main.lua,把注释里标了「改这里」的几行改掉就能用。不用再补别的代码。
只能选一段
一台设备同时只能跑一个 BACnet 节点,三段不要叠在一起。三段都需要 ESP32-S3 固件或软 PLC 模拟器,别的机型上 bacnet 模块不存在。
在模拟器上跑示例一(BACnet/IP)时,把 ifname 填成本机在局域网里的 IP,否则可能绑到虚拟网卡上。
示例一:BACnet/IP 从站(最省事,建议先跑这个)
一根网线就能验证,不用额外硬件。BMS/调试工具能读到两个点,还能写一个开关下来。
lua
--=== BACnet/IP 从站 ==========================================
-- 作用:把 D100(浮点温度)和 M10(开关)交给楼控系统读写
-- 前提:设备网口已经拿到 IP,且和 BMS 在同一个网段
--============================================================
-- 改这里:设备实例号,整个楼控网里不能和别人重复
local DEV_ID = 32001
-- 改这里:BMS 设备列表里显示的名字
local DEV_NAME = 'LP3232-01'
-- node 必须是全局变量。写成 local 会被垃圾回收,节点跑一会儿就自己停了
node = bacnet.creat({
link = 'ip',
role = 'slave',
ifname = '', -- 留空=自动挑网卡;固定走网口写 'eth',走 WiFi 写 'sta'
port = 47808, -- BACnet 标准端口,一般不用改
device_instance = DEV_ID,
device_name = DEV_NAME,
location = '一层设备间',
})
if node == nil then
print('BACnet 节点创建失败:参数不对')
return
end
-- 给 BMS 看的:供水温度。浮点占 D100+D101 两个寄存器
-- 参数依次是:地址、数据类型、绑哪个寄存器、读写权限、点名、单位
node:add_object('AV:1', bacnet.FLOAT, 'D100', bacnet.READ, '供水温度', { units = 62 })
-- 让 BMS 能控的:1号泵启停。开关量要用 BV,用 BI 的话 BMS 写不进去
node:add_object('BV:1', bacnet.BITE, 'M10', bacnet.READ_WRITE, '1号泵启停')
-- BMS 写下来的时候会回调这里,effective=true 表示这个值真的生效了
node:reg(bacnet.WRITE, function(addr, value, priority, effective)
if effective then
print('BMS 下发', addr, value)
end
end)
if node:start() ~= 1 then
print('BACnet 启动失败:网卡还没拿到 IP,或者 47808 端口被占用')
return
end
print('BACnet/IP 已启动,设备实例号 ' .. DEV_ID)
-- 下面这段只是为了让你看到数据在动。真实项目里 D100 由梯形图或采集程序写
-- 注意用定时器而不是 while + system.sleep:sleep 不泵消息队列,
-- 上面 node:reg() 注册的回调会一次都不触发
local t = 20.0
timer.start(2000, function()
t = t + 0.1
if t > 30.0 then t = 20.0 end
plc.df(100, t) -- 写浮点到 D100(占 D100+D101)
local s = node:stat()
print(string.format('在线=%s 收=%d 发=%d 温度=%.1f',
tostring(node:is_online()), s.rx_frames, s.tx_frames, t))
end)跑起来之后应该看到:调试工具里出现实例号 32001 的设备,展开有 analog-value 1(读数在 20~30 之间来回爬)和 binary-value 1。把 binary-value 1 写成 active,PLC 的 M10 会置位。
示例二:MS/TP 从站(接到现场的 RS-485 总线上)
和示例一只有 creat 的参数不一样,点表部分完全相同。
lua
--=== BACnet MS/TP 从站 =======================================
-- 作用:同上,但走 RS-485 接进现场总线
-- 前提:A/B 接线正确、两端各一个 120Ω、全线波特率一致
--============================================================
local DEV_ID = 32001
local DEV_NAME = 'LP3232-01'
node = bacnet.creat({
link = 'mstp',
role = 'slave', -- 纯从站不抢令牌,能少一半总线流量
com = 2, -- 改这里:用第几路串口。不能和 com/pipe 抢同一个口
baud = 76800, -- 改这里:全线一致。BACnet 常用 76800/38400
mac = 5, -- 改这里:本机 MAC,1~127,总线上不能重复
max_master = 127, -- 总线上主站 MAC 的上限,填小一点找令牌更快
device_instance = DEV_ID,
device_name = DEV_NAME,
})
if node == nil then
print('BACnet 节点创建失败:MAC 必须 1~127')
return
end
node:add_object('AV:1', bacnet.FLOAT, 'D100', bacnet.READ, '供水温度', { units = 62 })
node:add_object('AV:2', bacnet.FLOAT, 'D102', bacnet.READ, '回水温度', { units = 62 })
node:add_object('AV:3', bacnet.FLOAT, 'D104', bacnet.READ, '相对湿度', { units = 29 })
node:add_object('BI:1', bacnet.BITE, 'M11', bacnet.READ, '水泵故障')
node:add_object('BV:1', bacnet.BITE, 'M10', bacnet.READ_WRITE, '1号泵启停')
node:reg(bacnet.WRITE, function(addr, value, priority, effective)
if effective then print('BMS 下发', addr, value) end
end)
node:reg(bacnet.OFFLINE, function() print('总线断了') end)
node:reg(bacnet.ONLINE, function() print('总线恢复') end)
if node:start() ~= 1 then
print('BACnet 启动失败:串口号/波特率不对,或者该串口已被别的功能占用')
return
end
print('BACnet MS/TP 已启动,MAC 5')
-- 周期打印用定时器。这里如果写成 while true do ... system.sleep(5000) end,
-- 上面 reg 的 WRITE / OFFLINE / ONLINE 回调就全都收不到了
timer.start(5000, function()
local s = node:stat()
print(string.format('在线=%s 收=%d 发=%d CRC错=%d',
tostring(node:is_online()), s.rx_frames, s.tx_frames, s.crc_errors))
end)跑起来之后应该看到:收 和 发 一直在涨(总线上的令牌帧也算)。一直是 0 就是接线或波特率的问题,先查 A/B 有没有接反。收 在涨但 发 不涨,说明总线上的帧不是发给本机的 —— 对方的 MAC 或设备实例号填错了。
纯从站扫不到,要在 BMS 里手工填 MAC 5 和设备实例号,原因见 role 的说明。
示例三:主站 —— 去采一台空调机组
本机主动读取其他设备的点,值落进自己的寄存器,之后梯形图、云端、组态画面都能直接用。
lua
--=== BACnet 主站:采一台空调机组 =============================
-- 作用:每 5 秒读对方的送风温度和风机状态,落到 D400 / M406
-- 另外把 D410 的温度设定回写给对方
--============================================================
local TARGET = 12 -- 改这里:对方的 MS/TP MAC。BACnet/IP 就写 '192.168.1.50'
node = bacnet.creat({
link = 'mstp',
role = 'master',
com = 2, -- 改这里
baud = 76800, -- 改这里
mac = 1, -- 本机 MAC,别和对方重复
device_instance = 32001,
apdu_timeout = 3000, -- 等应答多久算超时
retries = 2, -- 超时后重发几次
})
if node == nil then print('节点创建失败'); return end
-- 只读点:对方的 AI:1 → 本机的 D400,每 5 秒一次
-- 连续 3 次读不到就把 M500 置位,恢复后自动复位
node:add_poll(TARGET, 'AI:1', bacnet.FLOAT, 'D400', {
interval = 5000,
fail_flag = 'M500',
})
-- 开关量:对方的 BI:1 → 本机的 M406
node:add_poll(TARGET, 'BI:1', bacnet.BITE, 'M406', { interval = 5000 })
-- 可回写点:本机改了 D410,下一轮就写给对方的 AV:10
-- priority 8 会占住对方的优先级槽,close() 时会自动释放
node:add_poll(TARGET, 'AV:10', bacnet.FLOAT, 'D410', {
interval = 5000,
access = bacnet.READ_WRITE,
priority = 8,
on_change = function(value, addr)
print('对方的值变了', addr, value)
end,
})
node:reg(bacnet.ERROR, function(code, addr)
print('读写失败', addr, '错误码', code)
end)
if node:start() ~= 1 then print('启动失败'); return end
print('BACnet 主站已启动,目标 ' .. tostring(TARGET))
-- 周期打印用定时器。写成 while true do ... system.sleep(5000) end 的话,
-- 上面 on_change 和 reg(bacnet.ERROR) 的回调一次都不会触发
timer.start(5000, function()
print(string.format('送风温度=%.1f 风机=%s 超时次数=%d',
plc.df(400), tostring(plc.m(406)), node:stat().timeouts))
end)跑起来之后应该看到:D400 出现对方的送风温度并每 5 秒刷新。把对方断电,约半分钟后 M500 置位(连续 3 次失败才置位,每次失败要走完 3000ms × 3 次 = 9 秒)。
不知道对方有哪些点?
先用调试模式扫一遍。总线上的设备和它们的点表都能列出来:
lua
node = bacnet.creat({ link = 'mstp', role = 'master', com = 2, baud = 76800, mac = 1 })
node:start()
system.sleep(2000)
node:who_is() -- 广播一次,问「谁在线」
system.sleep(3000)
-- who_is() 是异步的:第一次调用一般是空表,等对方的 I-Am 回来后再调一次才有内容
local found = node:who_is()
for _, dev in ipairs(found) do
print('发现设备 实例号=' .. dev.device .. ' MAC=' .. tostring(dev.mac))
-- 列出这台设备有哪些点
local list = node:object_list(dev.mac)
if list.errno == 0 then
for _, o in ipairs(list.data) do
print(' ' .. o.type .. ':' .. o.instance)
end
else
print(' 读点表失败,错误码 ' .. list.errno)
end
end不写 Lua:在上位机里配(DTU 配置)
不想写脚本的话,整套 BACnet 可以在上位机里点出来。DTU 引擎内部调的就是本页这套 Lua API,配置字段和 Lua 参数一一对应,不存在「界面能配但固件不支持」。
界面位置:上位机 → DTU 功能 → BACnet 子标签页。完整界面说明见 DTU 配置式编程 · BACnet,本页往下讲的是字段含义与联调步骤。

上半部分是链路参数(链路类型 / 角色 / 设备实例号 / 设备名称),下半部分两张表分别对应两个角色:「从站对象」是其他设备读取本机,「主站轮询」是本机主动读取其他设备。按下面的步骤从上往下填即可。
看不到这一页说明机型不支持
BACnet 子标签页只在 ESP32-S3 固件和软 PLC 模拟器上显示,由上位机按设备能力(features.dtu.bacnet_supported)自动判断。普通 ESP32 / STM32 机型上这一页不出现,不是上位机版本旧了。
页面出现只代表固件里有 BACnet 协议栈,不代表点表配对了。配完仍要按 上手测试步骤 用 YABE 实测一遍。
先跟着填一遍(BACnet/IP,最省事)
不用理解任何 BACnet 概念,照抄这一遍就能通。目标是:让楼控系统读到本机的 D100(温度)、控本机的 M10(水泵)。
第 1 步|打开开关
勾上「启用 BACnet」。
第 2 步|基础参数,只填 4 项
| 界面项 | 填什么 | 为什么 |
|---|---|---|
| 链路类型 | BACnet/IP (UDP) | 走网线,不占串口 |
| 角色 | 自动 | 只做从站时它就是从站,加了轮询点自动变主站 |
| 设备实例号 | 32001 | 楼控网里的身份证号,不能和别的设备重复 |
| 设备名称 | LP3232-01 | BMS 设备列表里显示的名字 |
其余(UDP端口 47808、网卡「自动」、请求超时、重发次数)保持默认不动。
第 3 步|从站对象表,加 2 行
点「增加对象」两次,填成这样:
| 对象类型 | 地址 | 点名 | 数据类型 | PLC寄存器 | 读写 | 工程单位 |
|---|---|---|---|---|---|---|
AV 模拟值 | AV:1 | 供水温度 | 浮点 | D100 | 只读 | 62 |
BV 数字值 | BV:1 | 1号泵启停 | 位 | M10 | 可读写 | — |
为什么是 AV / BV,不是 AI / BI
AI/BI 在 BACnet 标准里是「采集量」,BMS 只能看不能写。选了它们,界面会把「读写」自动锁成只读。想让 BMS 能控,就必须用 AV/BV。 这条在跟客户对点表时就要讲清楚,否则现场一定会为「BMS 写 AI 写不进去」扯皮。
第 4 步|写下去
点「写入配置」→ 再点「重启生效」。只写不重启的话,旧配置还在跑。
第 5 步|验证
用 YABE(或客户的 BMS)发一次 Who-Is,应该看到实例号 32001、名字 LP3232-01 的设备;展开有 analog-value 1 和 binary-value 1。改 D100 的值,YABE 里读 AV:1 就跟着变;YABE 里把 BV:1 写成 active,M10 就置位。
这一遍走通了,再往下加点、或者切到 MS/TP,就只是重复同样的动作。
再加一个主站轮询(可选)
如果还要本机主动读取其他设备(比如读空调机组的送风温度),在「主站轮询」里点「增加设备」:
| 界面项 | 填什么 |
|---|---|
| 设备名 | 1号空调机组 |
| 对方IP(MS/TP 时是对方MAC) | 192.168.1.50 |
| 轮询周期(ms) | 5000 |
| 故障标志线圈 | M500 |
然后「增加采集点」,填对方的点:
| 对象类型 | 地址 | 点名 | 数据类型 | 落到寄存器 | 读写 |
|---|---|---|---|---|---|
AI 模拟输入 | AI:1 | 送风温度 | 浮点 | D400 | 只读 |
写入 + 重启后,D400 就会每 5 秒出现对方的送风温度。对方掉线约半分钟后 M500 置位。
对方的点表要跟设备厂家要
「对方的 AI:1 是送风温度」这种信息只有对方厂家知道。不知道的话先用 Lua 脚本扫一遍(见 拿来就能跑的示例 最后那段),或者直接找厂家要点表。
切到 MS/TP 要改什么
链路类型换成 MS/TP (RS-485),然后多填 3 项,其余(对象表、轮询表)一个字都不用改:
| 界面项 | 填什么 | 注意 |
|---|---|---|
| 串口号 | 2 | 不能和「串口设备」页用同一个口,冲突了会出红字 |
| 波特率 | 76800 | 全线必须一致,这是 BACnet 最常用的档 |
| 本机MAC | 5 | 1~127,总线上不能重复 |
接线见 上手测试步骤 里的接线图。
全部字段对照表
上面走一遍够用了,这张表是需要查细节时看的。
| 界面项 | JSON 字段 | 说明 |
|---|---|---|
| 链路类型 | link | mstp / ip |
| 角色 | role | auto(配了轮询点自动升级为主站)/ slave / master |
| 串口号 | com | 仅 MS/TP。不能和「串口设备」页冲突,冲突了会出红字警告 |
| 波特率 | baud_index | 仅 MS/TP。com_btr 枚举下标,界面只给 BACnet 标准的 5 档 |
| 本机MAC | mac | 仅 MS/TP,1~127。0 是广播地址,不能当本机地址 |
| 最大主站MAC | max_master | 仅 MS/TP。总线上主站 MAC 的上限,填小一点能缩短 Poll For Master 一圈的时间 |
| 应答延时 | reply_delay_ms | 仅 MS/TP,0~255 |
| UDP端口 | port | 仅 IP,默认 47808 |
| 网卡 | ifname | 仅 IP。留空=自动(以太网→WiFi),也可指定 eth/sta/ap |
| 设备实例号 | device_instance | 全网唯一,0~4194302。BMS 就靠这个认设备 |
| 设备名称 | device_name | BMS 设备列表里显示的名字 |
| 请求超时 / 重发次数 | apdu_timeout / retries | 只影响主站方向 |
从站对象(BMS 来读取本机)
一行一个对象,把 PLC 寄存器暴露成 BACnet 对象。
- 对象类型下拉改的是地址前缀,改完会自动带出对应的数据类型(
B*→位、MS*→枚举、其余→浮点)。 - 选了
AI/BI/MSI/DEV,「读写」会自动锁成只读。这是 BACnet 标准语义 —— Input 是采集量,BMS 写不进去。要让 BMS 能写,点表里必须用AV/BV/MSV。 - 「工程单位」是 BACnet 的
units枚举号,位类型不需要。常用:62=°C、64=°F、98=%、29=相对湿度%、96=ppm、54=kPa、19=kWh、48=kW、82=L、95=无单位(默认)。完整取值是 BACnet 标准的 Engineering Units 枚举。
主站轮询(本机主动读取其他设备)
按对方设备分组:组上填对方 MAC(或 IP)、轮询周期、故障标志线圈;组里的「采集点」才是具体点位。
- 「故障标志线圈」填
M500这样的位地址,该设备连续 3 次读失败就置位,恢复后自动复位。留空表示不用。 - 点位「读写」选可读写时会多出「写优先级」。用
1~15写下去会占住对方的优先级槽,node:close()退出时会自动写 NULL 释放。不确定就用16(最低优先级,等于不占槽)。 - 采集回来的值不是立刻落寄存器,而是由 PLC 扫描周期统一落地,最多晚一个扫描周期。
点表导入
现场点表基本都是 Excel,一行一行点进界面是这套配置里最费时间的事。对象表和每个轮询组各有一个「点表导入」按钮。
- 从 Excel 直接复制粘贴(Tab 分隔)就行,逗号 CSV 也认。
- 列顺序按 JSON 字段顺序:
- 对象表:
地址, 点名, 数据类型, PLC寄存器, 读写, 工程单位 - 采集点:
地址, 点名, 数据类型, PLC寄存器, 读写, 写优先级
- 对象表:
- 后面的列可以不给。表头行(第一列不像地址)自动跳过。
- 数据类型留空时按对象类型自动推。
AI/BI/MSI/DEV的读写列会被强制改成只读。
有任何一行填错就整批不导入,并列出行号和原因。半批导入更难查 —— 界面上看着都在、实际漏了几行,要到现场才发现点少了。会检查这些:
| 检查项 | 不查会怎样 |
|---|---|
| 地址格式、实例号 ≤ 4194302 | 写错固件解析不了,这个点直接不存在 |
| 数据类型必须在白名单里 | 写成 REAL/f32 之类,固件查表查不到会静默按浮点处理,开关量点会变成浮点 |
寄存器写法(D/M/S/X/Y 加数字,X/Y 按八进制不能出现 8、9,数值 ≤65535) | 写错固件 add_object 返回失败,这个点静默消失,界面上却还在 |
| 地址不能重复 | 固件按地址认点,重复的会互相覆盖,只剩最后一条 |
| 点数上限 64(采集点是所有设备加起来算) | 超出的点固件静默丢弃 |
| 工程单位 0~65535、写优先级 1~16 | 超范围会被截断成别的值 |
还有一类只提醒、不拦:数值类型绑到位区(比如 float 绑 M10),固件会降级成 0/1,读出来永远只有 0 或 1。这种写法有时是故意的,所以收下但会提示。
手工在界面上填也一样:地址和寄存器格子填错会标红,失焦时弹出具体原因。
粘贴示例:
AI:1 送风温度 float D400 read
BI:1 风机运行 bite M406 read
AV:10 温度设定 float D410 read_write 8配置完怎么生效
点「写入配置」→「重启生效」。配置存在设备的 config.lua 里,重启后由 DTU 引擎拉起节点。
对应的 JSON 长这样(「查看JSON」页能看到完整内容):
json
{
"bacnet": {
"enable": true,
"link": "ip",
"role": "auto",
"port": 47808,
"ifname": "",
"device_instance": 32001,
"device_name": "LP3232-01",
"objects": [
{ "addr": "AV:1", "name": "供水温度", "mode": "float", "var": "D100",
"access": "read", "units": 62 },
{ "addr": "BV:1", "name": "1号泵启停", "mode": "bite", "var": "M10",
"access": "read_write" }
],
"polls": [
{ "ip": "192.168.1.50", "name": "1号空调机组", "interval": 5000, "fail_flag": "M500",
"points": [
{ "addr": "AI:1", "name": "送风温度", "mode": "float", "var": "D400" },
{ "addr": "AV:10", "name": "温度设定", "mode": "float", "var": "D410",
"access": "read_write", "priority": 8 }
] }
]
}
}换成 MS/TP 就是把 link 改成 "mstp",加上 com / baud_index / mac,并把 polls[].ip 换成 polls[].mac(数字)。对象表和采集点表完全不用动。
objects[]、polls[]、polls[].points[] 三级都支持 "enable": false,临时停掉某个点不用把行删掉。
bacnet 是顶层字段,不放在 serials[] 里面 —— 主站从站是同一个节点、同一个串口,放进 serials[] 会让引擎按串口再起一套协议把串口抢走。
上手测试步骤
第一次验证建议先测 BACnet/IP:不需要额外硬件,一根网线就够,出问题也能直接用 Wireshark 抓。MS/TP 要额外准备一个 USB-RS485 转换器。
准备一个 BACnet 客户端
推荐 YABE(Yet Another BACnet Explorer,开源免费)。它同时支持 BACnet/IP 和 MS/TP,能扫设备、浏览对象、读写属性,够用来验证全部功能。
场景一:从站 —— 让 BMS 读到本机的数据(BACnet/IP)
确认 PLC 和电脑在同一个 IP 子网。本机没有实现 BBMD,跨网段一定不通。
上位机 → DTU → BACnet:启用,链路选
BACnet/IP (UDP),端口47808,网卡自动,设备实例号填一个网内唯一的值(比如32001)。从站对象表加两行试手:
AV:1,浮点,绑D100,只读BV:1,位,绑M10,可读写
写入配置 → 重启生效。
用梯形图或 Lua 给
D100写个能认出来的值。注意浮点对象占D100+D101两个寄存器,要用浮点写法:plc.df(100, 12.34)。YABE 里点
Add device,发一次Who-Is。
预期:设备列表里出现您填的实例号和设备名;展开能看到 analog-value 1 和 binary-value 1;点 AV:1 的 Present_Value 读出来是 12.34。
- 在 YABE 里把
BV:1的Present_Value写成active。
预期:PLC 的 M10 置位。写下去到 M10 变化最多晚一个扫描周期。
场景二:主站 —— 本机主动采集其他设备
需要总线/网络上有另一台 BACnet 设备。没有真设备的话,可以在另一台电脑上跑 YABE 自带的模拟设备,或者再拿一台 LP3232 按场景一配成从站,两台互相读。
- 主站轮询里「增加设备」,填对方的 IP(或 MS/TP 的 MAC)。
- 轮询周期先给
5000,故障标志线圈填M500。 - 加一个采集点:对方的
AV:1,浮点,落到D400。 - 写入配置 → 重启生效。
预期:D400 出现对方 AV:1 的值,并按 5 秒周期刷新。把对方那台断电,约半分钟后 M500 置位;恢复后 M500 自动复位。
置位要等这么久是因为:连续 3 次失败才置位(避免偶发抖动误报),而每次失败都要走完 请求超时 × (重发次数+1)。默认 3000ms × 4 = 12 秒一次,三次就是 36 秒左右。想让故障报得快就把「请求超时」和「重发次数」调小。
- 想验证回写:把这个点改成可读写、优先级
8,然后用梯形图改D400。
预期:下一轮轮询把新值写到对方的 AV:1,并且占住优先级 8 的槽。
场景三:MS/TP
接线:PLC 的 RS-485 A/B 接到 USB-RS485 转换器,两端都接 120Ω 终端电阻。
- 链路选
MS/TP,串口号选一个没在「串口设备」页用过的口(冲突了会出红字),波特率76800,本机MAC 比如5。 - YABE 里添加 MS/TP 端口:选转换器的 COM 口,波特率对齐
76800,YABE 自己的 MAC 填一个不冲突的值(比如1)。 - 其余步骤同场景一。
预期:MS/TP 是令牌环,总线上一直有令牌帧在转,所以 node:is_online() 的判离线窗口是 5 秒;BACnet/IP 没有这种旁证,窗口放宽到 60 秒。
角色是 slave 时 YABE 的 Who-Is 扫不到这台设备(从站发不出 I-Am,见 role 的说明)——在 YABE 里手工把 MAC 5 和设备实例号加进去,加完读写都正常。要能被扫到就把角色改成 master。
出问题往哪查
| 现象 | 先查什么 |
|---|---|
| YABE 扫不到设备 | 同子网?防火墙放行 UDP 47808?设备实例号和网内其它设备重复了?MS/TP 且角色是 slave 时扫不到是正常的,手工填 MAC 和实例号 |
| 写入配置后没反应 | 重启过吗?「查看JSON」里 bacnet 字段在不在? | | 串口一点动静都没有 | BACnet 页有没有红字警告说串口和「串口设备」页冲突了 | | 换过 BACnet 之后这个口的 Modbus 不通 | 不该发生,close() 会把串口模式和参数还原。真遇到了请报一下 | | BMS 写 AI 写不进去 | 正常。AI/BI/MSI 是标准里的采集量,要能写就换成 AV/BV/MSV | | 主站读一直超时 | 对方 MAC/IP 对不对?MS/TP 波特率两边一致吗?node:stat() 看 timeouts 和 crc_errors | | 收发帧数一直是 0 | 接线反了(A/B 对调)、没接终端电阻、或者对方压根没上电 |
手写 Lua 调试时,node:stat()、node:who_is() 和 node:object_list() 三个是最有用的:先确认总线上有谁,再列出它有哪些点,最后看通讯质量统计。
常见用法
纯从站:把 PLC 数据交给 BMS
lua
node = bacnet.creat({
link = 'mstp', role = 'slave',
com = 2, baud = 76800, mac = 5,
device_instance = 1001,
device_name = 'LPPLC-AHU-1',
location = '一层空调机房',
})
-- 采集量
node:add_object('AI:1', bacnet.FLOAT, 'D100', bacnet.READ, '回风温度', { units = 62 })
node:add_object('AI:2', bacnet.FLOAT, 'D102', bacnet.READ, '回风湿度', { units = 29 })
node:add_object('BI:1', bacnet.BITE, 'M10', bacnet.READ, '风机运行')
node:add_object('BI:2', bacnet.BITE, 'M11', bacnet.READ, '风机故障')
-- BMS 可下发的
node:add_object('AO:1', bacnet.FLOAT, 'D200', bacnet.READ_WRITE, '温度设定',
{ units = 62, relinquish_default = 24.0 })
node:add_object('BO:1', bacnet.BITE, 'M20', bacnet.READ_WRITE, '风机启停')
node:reg(bacnet.WRITE, function(addr, value, priority, effective)
if effective then print('BMS 下发生效', addr, value) end
end)
if node:start() ~= 1 then print('BACnet 启动失败,检查串口是否被占用') end主站:采一批温控器
lua
node = bacnet.creat({
link = 'mstp', role = 'master',
com = 2, baud = 38400, mac = 1,
device_instance = 1, apdu_timeout = 3000, retries = 2,
})
-- 8 个温控器,MAC 11~18,室温存到 D400 起,故障标志 M400 起
for i = 0, 7 do
node:add_poll(11 + i, 'AI:1', bacnet.FLOAT, 'D' .. (400 + i * 2), {
interval = 10000,
fail_flag = 'M' .. (400 + i),
})
end
node:start()BACnet/IP 从站:走网口接 BMS
lua
node = bacnet.creat({
link = 'ip', role = 'slave',
ifname = 'eth', -- 走网口;WiFi 就写 'sta'
port = 47808,
device_instance = 1001,
device_name = 'LPPLC-AHU-1',
location = '一层空调机房',
})
node:add_object('AI:1', bacnet.FLOAT, 'D100', bacnet.READ, '回风温度', { units = 62 })
node:add_object('AO:1', bacnet.FLOAT, 'D200', bacnet.READ_WRITE, '温度设定',
{ units = 62, relinquish_default = 24.0 })
-- 网卡可能还没拿到 IP,失败就重试
while node:start() ~= 1 do
print('BACnet/IP 启动失败,等网络...')
system.sleep(5000)
end
print('BACnet/IP 已启动,本机', node:stat().ip)调试:扫网络/总线上有什么设备,再列出它的点
lua
node = bacnet.creat({ link = 'mstp', role = 'master', com = 2, baud = 76800, mac = 1 })
node:start()
node:who_is()
system.sleep(3000)
for _, d in ipairs(node:who_is()) do
local dest = d.mac or d.ip
print('发现设备', d.device, dest)
local r = node:object_list(dest, d.device)
for _, o in ipairs(r.data) do
print(' ' .. o.type .. ':' .. o.instance)
end
end注意事项
回调要靠消息泵,别用 system.sleep 当延时
node:reg()注册的 WRITE / OFFLINE / ONLINE / ERROR 回调,以及add_poll的on_change,都是靠固件的消息泵从消息队列里取出来分发的。主线程里的system.sleep只是停住线程、不推进泵,所以脚本主循环写成while true do ... system.sleep(5000) end的话这些回调一个都不会触发。- 周期性的事情用
timer.start(ms, function() ... end),脚本return后固件自动接管消息泵。本页示例都是这个写法。 - 启动重试(
while node:start() ~= 1 do ... end)里用system.sleep没问题:那时候节点还没起来,也就没有回调要收。
MS/TP 接线与波特率
- MS/TP 就是 RS-485:一对双绞线,A/B 不能反,两端各一个 120Ω 终端电阻。
- 波特率必须和总线上其它设备完全一致,只支持 9600 / 19200 / 38400 / 57600 / 76800 / 115200。76800 是 BACnet 标准里的 MS/TP 常用速率,本机上位机的串口波特率下拉框里也有这一项。
- 串口参数固定 8N1,
start()会强制设成这个,不用也不能在上位机里改。
BACnet/IP 组网
- 必须和 BMS 在同一个 IP 子网。 本机不做 BBMD,也不做外部设备注册(Register-Foreign-Device),跨子网的 BVLC 请求一律回 NAK。要跨网段得由现场的 BBMD 设备转发。
- 广播用的是定向广播(本网段广播地址,如
192.168.1.255),不是255.255.255.255。所以本机的子网掩码要配对,掩码错了 Who-Is / I-Am 就发不到该去的地方。 - UDP 端口默认 47808。同一台设备上不能有两个程序抢同一个端口,端口被占用时
start()返回0。 - 不支持 BACnet/IPv6(Annex U)。
地址规划
- MS/TP 本机
mac必须 1~127,0 是广播地址。 device_instance要全网唯一,冲突了 BMS 会认错设备。- MS/TP 总线上所有主站的
max_master要一致,否则令牌会绕不回来。
性能
- 对象和轮询点各上限 64 个。
- 只做采集就把
role设'slave':MS/TP 下不参与令牌环,总线流量少一半。 reply_delay_ms(MS/TP)最大 255。这个值是「收到请求到发出应答」的允许延迟,BACnet 标准要求 ≤ 250ms。留太小容易超时,留太大拖慢整个令牌环。用stat().max_reply_ms看实际余量。- 主站方向是单点 ReadProperty,没有 ReadPropertyMultiple 批量合并。从站方向支持 RPM,BMS 用 RPM 读取本机没有问题。
和其它模块的关系
- 一个串口只能给一家用。MS/TP 节点
start()时如果该口已被com/pipe或固件内置协议占用,start()返回0,不会静默抢过来。close()之后串口才还给别人,模式和参数也会还原成start()之前的值。 - BACnet/IP 不占串口,和串口模块互不影响。
还没实现的部分
- BACnet/IPv6、BBMD / 外部设备注册、COV 订阅(BMS 只能轮询,不能订阅变化)。
- 主站方向没有 ReadPropertyMultiple。
- DeviceCommunicationControl 不支持(BMS 发过来会收到
Reject: unrecognized-service)。 这是故意的:这个服务能让整台设备停止 BACnet 通信,而口令目前没有配置入口, 开着等于给楼宇网上任何人留了一个远程停机开关。常规采集不依赖它。 - 只有 ESP32-S3 固件和软 PLC 模拟器支持(普通 ESP32 的 app 分区放不下这个协议栈)。模拟器上 BACnet/IP 与真机一致,MS/TP 只能验逻辑不能验兼容性。
例程
每个例程都是可直接下载运行的完整脚本(仅 LP3232 / LP3288 / 软PLC模拟器支持)。
creat只做配置,真正上总线在start()。统一模板「场景 / 完整代码 / 要点 / 验证方法 / 注意事项」。
例程 1:把温度/阀门暴露成 BACnet 对象(MS/TP 从站)
适用:LP3232 / LP3288 / 软PLC模拟器 | 依赖模块:bacnet plc | 难度:★☆☆
场景:一台 LP3232 挂在 485 总线上当 BACnet 从站,把 D100 温度、Y0 阀门暴露给 BMS。
完整代码(可直接下载运行):
lua
node = bacnet.creat({
link = 'mstp', role = 'slave', com = 2, baud = 76800, mac = 5,
device_instance = 1001, device_name = 'LPPLC-1F' })
node:add_object("AV:1", bacnet.FLOAT, "D100", bacnet.READ) -- 温度
node:add_object("BV:1", bacnet.READ_WRITE, "Y0", bacnet.READ_WRITE) -- 阀门
node:start()要点:creat 返回对象必须用全局变量接住(被 GC 回收会停机并释放串口);start() 才真正上总线。
验证方法:在 BMS 里手工添加设备(填 MAC=5、实例号=1001),读 AV:1 应看到 D100 的值。
注意事项:普通 ESP32 没有 bacnet 模块(type(bacnet) ~= 'table');MS/TP 从站收得到 Who-Is 但答不出 I-Am,BMS 扫描扫不到,必须手工填地址;mac 取 1~127(0 是广播不能用)。
例程 2:BACnet/IP 从站 + 本机当主站轮询另一台
适用:同上 | 依赖模块:bacnet | 难度:★★☆
场景:走网口暴露本机数据,同时本机当主站去轮询另一台 BACnet 设备。
完整代码(可直接下载运行):
lua
-- 本机当 IP 从站暴露数据
node = bacnet.creat({ link='ip', role='slave', ifname='eth',
port=47808, device_instance=1001 })
node:add_object("AI:1", bacnet.S16, "D100", bacnet.READ)
node:start()
-- 本机同时当主站去轮询另一台
node2 = bacnet.creat({ link='ip', role='master', device_instance=2001 })
node2:add_poll("AV:1", "D200", 2000) -- 轮询对方 AV:1 → D200,每 2s
node2:start()要点:IP 下从站也能正常回 I-Am,BMS 可扫描到;role='auto' 下一旦调了 add_poll 就自动变 master。
验证方法:BMS 能扫到本机;对方 AV:1 的值进入本机 D200。
注意事项:ifname 不支持 4G(PPP);auto 角色下没 add_poll 则等同 slave,不会主动发请求。
例程 3:注意事项——平台判断与从站发现
适用:同上 | 依赖模块:bacnet | 难度:★☆☆
完整代码(可直接下载运行):
lua
-- ❌ 错误:普通 ESP32 上 bacnet 为 nil
node = bacnet.creat({...}) -- attempt to index a nil value
-- ✅ 正确:先判平台再建
if type(bacnet) == 'table' then
node = bacnet.creat({ link='ip', role='slave', ifname='eth',
device_instance=1001 })
node:start()
end要点:上架前用 type(bacnet) == 'table' 确认平台支持;返回对象必须全局持有。
验证方法:支持平台拿到对象并 start;不支持平台跳过不报错。
注意事项:普通 ESP32/STM32 固件无此模块;MS/TP 从站扫不到要在 BMS 手工填地址(这是协议固有限制,不是配置错)。