Skip to content

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 点」:

从站角色:把 PLC 寄存器变成 BACnet 点,让 BMS 来读

主站 —— 本机主动读取其他设备。您要做的只是「去谁那里、读哪个点、多久一次、放到本机哪个寄存器」:

主站角色:本机主动读取其他设备的点,值落进自己的寄存器

对应的 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含义可写
AI0Analog Input,模拟采集量否
AO1Analog Output,模拟输出是
AV2Analog Value,模拟中间量是
BI3Binary Input,开关采集量否
BO4Binary Output,开关输出是
BV5Binary Value,开关中间量是
MSI13Multi-state Input,多状态采集否
MSO14Multi-state Output,多状态输出是
MSV19Multi-state Value,多状态中间量是

AI / BI / MSI 在标准里就是采集量。给它们配 bacnet.READ_WRITE 会被静默降级成只读,不会报错。要让 BMS 能写就用 AO / AV / BO / BV / MSO / MSV。

常量 ​

数据类型(add_object / add_poll 的 mode 参数)

常量值说明
bacnet.BITE0位。绑定到 M/X/Y/S 线圈,配 BI/BO/BV
bacnet.S16116 位有符号,占 1 个 D 寄存器
bacnet.U16216 位无符号,占 1 个 D 寄存器
bacnet.S32332 位有符号,占 2 个 D 寄存器
bacnet.U32432 位无符号,占 2 个 D 寄存器
bacnet.FLOAT5单精度浮点,占 2 个 D 寄存器
bacnet.DOUBLE6双精度浮点
bacnet.ENUM7枚举,配 MSI/MSO/MSV

读写属性

常量值说明
bacnet.READ0只读
bacnet.READ_WRITE1可写。从站侧带 16 级优先级;主站侧寄存器变化时自动回写

事件(传给 node:reg)

常量说明
bacnet.WRITEBMS 写了本机的可写对象
bacnet.ONLINE总线恢复通讯
bacnet.OFFLINE总线通讯中断(5 秒没收到有效报文)
bacnet.ERROR通讯错误(超时 / CRC / 被拒)

错误码 ​

read / write 返回的 errno,以及 bacnet.ERROR 回调的第一个参数:

值含义
0成功
8501超时,对方没应答
8502CRC 校验错,接线或波特率问题
8503帧格式错
8504串口未打开 / 节点没启动
8505地址非法(MAC 不在 1~127,或 addr 写错)
8506APDU 长度超限
8601对方说没有这个对象
8602对方说没有这个属性
8603对方拒绝写入(只读属性)
8604值超范围
8605数据类型不匹配
8606服务被拒绝
8607对方忙

bacnet.creat ​

lua
bacnet.creat(cfg) --> node | nil

创建一个 BACnet 节点对象。只做配置,不占用串口,真正上总线是在 start。

参数

cfg 是一张配置表,缺省项用默认值:

键类型默认值说明
linkstring'mstp''mstp' = RS-485;'ip' = BACnet/IP
rolestring'auto''slave' / 'master' / 'auto'
device_instancenumber32001Device 对象实例号,全网唯一,上限 4194302
apdu_timeoutnumber3000主站请求超时(ms)
retriesnumber3主站重试次数
vendor_idnumber0厂商代码
device_namestring—Device 对象名,BMS 上显示的名字
descriptionstring—Device 描述
locationstring—安装位置
vendor_namestring—厂商名
model_namestring—型号

link = 'mstp' 时额外用到:

键类型默认值说明
comnumber2串口号 1~3
baudnumber76800只接受 9600 / 19200 / 38400 / 57600 / 76800 / 115200
macnumber5本机 MS/TP 地址 1~127(0 是广播,不能用)
max_masternumber127令牌环里最大主站地址,和总线上其它设备保持一致
reply_delay_msnumber200Treply_delay,最大 255,超过会被截断

link = 'ip' 时额外用到:

键类型默认值说明
portnumber47808本机 UDP 端口。BACnet 标准端口是 47808(0xBAC0)
ifnamestring自动用哪张网卡:'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。

参数

#参数类型必填默认值说明
1addrstring是—对象地址,如 "AV:1",见对象地址写法
2modenumber是—数据类型常量,如 bacnet.FLOAT
3varstring / number / userdata是—绑定目标,见下
4accessnumber否bacnet.READbacnet.READ 或 bacnet.READ_WRITE
5namestring否自动生成 AI_1对象名,BMS 点表里显示的名字
6opttable否—见下

var 支持三种写法:

  • 字符串:寄存器名,"D100" / "M10" / "M8013" / "X0" / "Y0" / "S20"。X/Y 按八进制,出现数字 8 或 9 会解析失败。
  • 数字:mode 是 bacnet.BITE 时当 M 地址,否则当 D 地址。
  • userdata:data.creat() 建的共享变量。

opt 可选项:

键类型默认值说明
unitsnumber95BACnet 工程单位码,95 = no-units。摄氏度 62、% 98、kWh 19
statesnumber0MSI/MSO/MSV 的 Number_Of_States,不配则上报 255
relinquish_defaultnumber0可写对象在 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 且不生效。

参数

#参数类型必填说明
1addrstring是对象地址,"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 设备的点,值自动落到本机寄存器。

参数

#参数类型必填默认值说明
1destnumber / string是—对方地址,见目标地址怎么写
2addrstring是—对方的对象地址,如 "AI:1"
3modenumber是—数据类型常量
4varstring / number / userdata是—值存到哪,写法同 add_object
5opttable否—见下

opt 可选项:

键类型默认值说明
intervalnumber5000轮询周期(ms),最小 100,小于 100 会被抬到 100
accessnumberbacnet.READ配 bacnet.READ_WRITE 时,本机寄存器被改动会自动写给对方
prioritynumber16回写用的优先级 1~16
fail_flagstring—连续 3 次读失败就置位的线圈,如 "M400";恢复后自动复位
on_changefunction—值变化回调,签名 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 且不生效。

参数

#参数类型必填说明
1destnumber / string是对方地址,写法与 add_poll 第一个参数一致(MS/TP 给 MAC 数字,BACnet/IP 给 IP 字符串)。它参与匹配:从 12 号站读回来的点和从 13 号站读回来的 AI:1 是两个点,删的时候也得带上对应的地址
2addrstring是"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 做四件事:

  1. com_claim 占用串口。被 com 或 pipe 占着就直接失败,不会静默抢过来。
  2. 把该串口切成 BACnet MS/TP 模式,参数固定 8N1 + 配置的波特率,并写入 flash(已经是这套参数就不重复写,省 NVS 磨损)。切换前的模式和参数会记下来,close() 或启动中途失败时原样还原 —— 用过 BACnet 之后这个口的 Modbus 不会莫名不通。
  3. 打开协议栈。
  4. 建一个独立的 FreeRTOS 任务跑 MS/TP 令牌环和接收状态机。

BACnet/IP 做三件事:

  1. 按 ifname 挑一张已经拿到 IP 的网卡,读出本机 IP 和子网掩码,算出定向广播地址(IP | ~掩码,不是 255.255.255.255 —— 路由器一律不转发它,有些 BMS 也不认)。
  2. 建 UDP 套接字绑到 0.0.0.0:port,开 SO_BROADCAST,设成非阻塞。
  3. 建协议任务,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, ... } }

主站同步读。第二个参数传数组时按批量读。

参数

#参数类型必填默认值说明
1destnumber / string是—对方地址,见目标地址怎么写
2addrstring / string 数组是—"AI:1" 这种对象地址;传数组就是连着读多个对象
3countnumber否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:

情况errnodata
全部读到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 里带的属性号会被忽略。常用属性号:

属性号名称
77Object_Name
85Present_Value
87Priority_Array
104Relinquish_Default
117Units

参数

#参数类型必填说明
1destnumber / string是对方地址,见目标地址怎么写
2addrstring是"AI:1" 这种「类型:实例号」。这一串里的属性号不参与本次请求(写了也会被第三个参数盖掉),只用来定位对象
3prop_idnumber是属性号,整数。不填直接抛 Lua 错误。号码合不合法不校验,对方不认就回 8602

返回值

{ errno = ..., data = ... }:

情况errnodata
读到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。

参数

#参数类型必填默认值说明
1destnumber / string是—对方地址
2addrstring是—"AO:1" 这种。发出去的 BACnet 数据类型由这里的对象类型缩写决定,不需要也别指望有个 mode 参数
3valuenumber / boolean是—布尔按 1/0 处理。其它类型抛 Lua 错误
4prioritynumber否16写进对方哪一个优先级槽。0 或超出 1~16 的值一律被改成 16,不会报错

按对象类型发出去的数据类型:

addr 的类型实际发出的 BACnet 类型value 怎么用
BI / BO / BVEnumerated0 → INACTIVE,非 0 → ACTIVE
MSI / MSO / MSVUnsigned 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,值已经进它那个优先级槽
8505addr 写法不合法(报文根本没发出去),或 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。

参数

#参数类型必填默认值说明
1destnumber / string是—对方地址
2addrstring是—"AO:1" 这种。「写哪个槽」由 priority 决定,addr 只负责定位对象;带了属性号就按那个属性释放,默认 Present_Value
3prioritynumber否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)
end

node:object_list ​

lua
node:object_list(dest, [device_instance, max])
  --> { errno = 0, data = { { type = "AI", type_id = 0, instance = 1 }, ... } }

读对方 Device 对象的 Object_List,也就是「这台设备有哪些点」。

参数

#参数类型必填默认值说明
1destnumber / string是—对方地址
2device_instancenumber否自动反查对方的 Device 实例号
3maxnumber否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
end

node:stat ​

lua
node:stat() --> table

通讯质量统计。

键类型说明
rx_framesnumberMS/TP:收到的有效帧数;IP:收到的 UDP 报文数
tx_framesnumber发出的帧数 / 报文数
crc_errorsnumberMS/TP:CRC 错误帧数,持续增长说明接线/终端电阻/波特率有问题。IP 下没有 CRC 这回事,这个位置给的是被丢弃的报文数(BVLC 解析失败、来自本机、广播里夹带 Confirmed 请求)
timeoutsnumber主站请求超时次数
last_rx_msnumber距上次收到有效报文多少毫秒
max_reply_msnumber历史最大应答耗时(ms),见下
ipstring本机 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.WRITEfunction(addr, value, priority, effective)
bacnet.ONLINEfunction()
bacnet.OFFLINEfunction()
bacnet.ERRORfunction(code, addr)

bacnet.WRITE 的 effective 表示这次写入是否成为了当前生效值 —— BMS 用优先级 10 写、而优先级 5 的槽还占着时,effective 是 false。

参数

#参数类型必填说明
1eventnumber是bacnet.WRITE / bacnet.ONLINE / bacnet.OFFLINE / bacnet.ERROR 四个之一
2callbackfunction是不是函数直接抛 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,本页往下讲的是字段含义与联调步骤。

上位机 → DTU 功能 → BACnet:链路选 BACnet/IP,下面「从站对象」一行一个点,把 D100 / D102 发布给 BMS 读

上半部分是链路参数(链路类型 / 角色 / 设备实例号 / 设备名称),下半部分两张表分别对应两个角色:「从站对象」是其他设备读取本机,「主站轮询」是本机主动读取其他设备。按下面的步骤从上往下填即可。

看不到这一页说明机型不支持

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-01BMS 设备列表里显示的名字

其余(UDP端口 47808、网卡「自动」、请求超时、重发次数)保持默认不动。

第 3 步|从站对象表,加 2 行

点「增加对象」两次,填成这样:

对象类型地址点名数据类型PLC寄存器读写工程单位
AV 模拟值AV:1供水温度浮点D100只读62
BV 数字值BV:11号泵启停位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 最常用的档
本机MAC51~127,总线上不能重复

接线见 上手测试步骤 里的接线图。


全部字段对照表 ​

上面走一遍够用了,这张表是需要查细节时看的。

界面项JSON 字段说明
链路类型linkmstp / ip
角色roleauto(配了轮询点自动升级为主站)/ slave / master
串口号com仅 MS/TP。不能和「串口设备」页冲突,冲突了会出红字警告
波特率baud_index仅 MS/TP。com_btr 枚举下标,界面只给 BACnet 标准的 5 档
本机MACmac仅 MS/TP,1~127。0 是广播地址,不能当本机地址
最大主站MACmax_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_nameBMS 设备列表里显示的名字
请求超时 / 重发次数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 转换器。

两种接法:先用网口试通,再上 RS-485

准备一个 BACnet 客户端 ​

推荐 YABE(Yet Another BACnet Explorer,开源免费)。它同时支持 BACnet/IP 和 MS/TP,能扫设备、浏览对象、读写属性,够用来验证全部功能。

场景一:从站 —— 让 BMS 读到本机的数据(BACnet/IP) ​

  1. 确认 PLC 和电脑在同一个 IP 子网。本机没有实现 BBMD,跨网段一定不通。

  2. 上位机 → DTU → BACnet:启用,链路选 BACnet/IP (UDP),端口 47808,网卡 自动,设备实例号填一个网内唯一的值(比如 32001)。

  3. 从站对象表加两行试手:

    • AV:1,浮点,绑 D100,只读
    • BV:1,位,绑 M10,可读写
  4. 写入配置 → 重启生效。

  5. 用梯形图或 Lua 给 D100 写个能认出来的值。注意浮点对象占 D100+D101 两个寄存器,要用浮点写法:plc.df(100, 12.34)。

  6. YABE 里点 Add device,发一次 Who-Is。

预期:设备列表里出现您填的实例号和设备名;展开能看到 analog-value 1 和 binary-value 1;点 AV:1 的 Present_Value 读出来是 12.34。

  1. 在 YABE 里把 BV:1 的 Present_Value 写成 active。

预期:PLC 的 M10 置位。写下去到 M10 变化最多晚一个扫描周期。

场景二:主站 —— 本机主动采集其他设备 ​

需要总线/网络上有另一台 BACnet 设备。没有真设备的话,可以在另一台电脑上跑 YABE 自带的模拟设备,或者再拿一台 LP3232 按场景一配成从站,两台互相读。

  1. 主站轮询里「增加设备」,填对方的 IP(或 MS/TP 的 MAC)。
  2. 轮询周期先给 5000,故障标志线圈填 M500。
  3. 加一个采集点:对方的 AV:1,浮点,落到 D400。
  4. 写入配置 → 重启生效。

预期:D400 出现对方 AV:1 的值,并按 5 秒周期刷新。把对方那台断电,约半分钟后 M500 置位;恢复后 M500 自动复位。

置位要等这么久是因为:连续 3 次失败才置位(避免偶发抖动误报),而每次失败都要走完 请求超时 × (重发次数+1)。默认 3000ms × 4 = 12 秒一次,三次就是 36 秒左右。想让故障报得快就把「请求超时」和「重发次数」调小。

  1. 想验证回写:把这个点改成可读写、优先级 8,然后用梯形图改 D400。

预期:下一轮轮询把新值写到对方的 AV:1,并且占住优先级 8 的槽。

场景三:MS/TP ​

接线:PLC 的 RS-485 A/B 接到 USB-RS485 转换器,两端都接 120Ω 终端电阻。

  1. 链路选 MS/TP,串口号选一个没在「串口设备」页用过的口(冲突了会出红字),波特率 76800,本机MAC 比如 5。
  2. YABE 里添加 MS/TP 端口:选转换器的 COM 口,波特率对齐 76800,YABE 自己的 MAC 填一个不冲突的值(比如 1)。
  3. 其余步骤同场景一。

预期: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 手工填地址(这是协议固有限制,不是配置错)。

LPPLC 工业物联网云平台 · 软硬件与云平台全栈自研