主题
综合例程:Modbus 温度采集 + MQTT 上报 + 云端下发控制继电器
一个端到端的完整工程:RS485 读温控表 → 定时 MQTT 上报 → 订阅云端下发 → 控制继电器 → 回 ACK。 这是物联网网关最典型的形态,也是 AI 最容易写错的一种(见第五节)。
一、场景与软元件分配
现场有一台 RS485 温控表(Modbus RTU 从站地址 1,温度存在保持寄存器 40001,浮点), 需要每 10 秒把温度发到云端,并接受云端下发指令控制本地继电器 Y0。
| 位置 | 地址 | 说明 |
|---|---|---|
| 温控表保持寄存器 | 40001(浮点,2 个寄存器) | 温度原始值 |
| 本地 PLC | D100 / D101 | 采集落点(浮点占用两个 D) |
| 本地 PLC | Y0 | 被控继电器 |
| 从站通信状态 | st_temp(data 对象) | 0=掉线,非 0=在线 |
| MQTT 上报主题 | lp3232/dev01/uplink | 设备 → 云端 |
| MQTT 下发主题 | lp3232/dev01/downlink | 云端 → 设备 |
二、完整代码(可直接下载运行)
lua
-- ============================================================
-- 综合例程:Modbus RTU 采集温度 → MQTT 定时上报 → 云端下发控继电器
-- 关键点:全程事件驱动,脚本里没有任何阻塞主循环
-- ============================================================
-- ---------- 1. 采集:RS485 温控表 → D100(浮点) ----------
local st_temp = data.creat(0) -- 从站通信状态
local dev = com_modbus.creat(com_modbus.RTU, 1, st_temp) -- RTU,从站地址 1
dev:set_float_abcd(com_modbus.CDAB) -- 国产表常见字序
dev:set_time_recv(500)
dev:add_channel(400001, com_modbus.FLOAT, 100, com_modbus.READ) -- 40001 → D100/D101
local port = com.setup(1, 9600, com.NONE, 8, 1) -- 串口1 9600 8N1,按现场改
port:add_device(dev)
port:set_time_lunxun(1000) -- 1 秒轮询一次
port:start() -- 后台线程自动采集
-- ---------- 2. 通信:MQTT ----------
local PUB = "lp3232/dev01/uplink"
local SUB = "lp3232/dev01/downlink"
mqttc = mqtt.creat("broker.emqx.io", 1883, "lp3232_dev01", "", "") -- 必须长期持有,用全局变量
mqtt.set_keepalive(mqttc, 60) -- 必须在 start 之前
mqtt.reg(mqttc, mqtt.CONNECTED, function()
print("MQTT 已连接")
mqtt.subscribe(mqttc, SUB, 0) -- 连上再订阅
end)
mqtt.reg(mqttc, mqtt.RECVDATA, function(topic, payload)
local ok, cmd = pcall(json.decode, payload)
if not ok then
print("下发不是合法 JSON:", payload)
return
end
-- 云端下发 {"relay":1} 吸合、{"relay":0} 断开
if cmd.relay ~= nil then
plc.y(0, cmd.relay)
mqtt.publish(mqttc, PUB, json.encode({
ack = "relay_set", value = cmd.relay, ts = os.time()
}), 0, 0)
print("已执行下发, relay =", cmd.relay)
end
end)
mqtt.start(mqttc) -- 内部线程自动重连,不需要自己写重连逻辑
-- ---------- 3. 定时上报:每 10 秒 ----------
timer.start(10000, function()
if mqtt.is_linked(mqttc) ~= 1 then return end -- is_linked 返回整数,必须 == 1
local msg = json.encode({
dev = "lp3232_dev01",
temp = plc.df(100), -- 浮点用 df(),不能写 plc.D100
online = st_temp:get_num(),
ts = os.time()
})
mqtt.publish(mqttc, PUB, msg, 0, 0)
print("已上报:", msg)
end)
print("启动完成:采集由 com 轮询线程驱动,上报由定时器驱动,脚本到此结束")三、逐段要点
- 采集不用自己发报文:
com_modbus只描述「从站有哪些通道」,com.start起的轮询线程会按set_time_lunxun的间隔自动遍历,值直接落到 D 寄存器 /data对象,CRC 和帧时序都由固件处理。脚本里不要再对同一串口手动send。 status参数建议传:com_modbus.creat的第 3 参把通信状态回写到data对象,用来区分「从站掉线」和「数值确实是 0」。不传的话采不到数据时无从判断。- MQTT 客户端必须长期持有:
mqttc用全局变量(或被长生命周期变量引用)。被 GC 回收会直接断连。 RECVDATA要在start之前注册,否则可能漏掉早期消息;订阅放在CONNECTED回调里,因为未连接时subscribe会失败。- 周期上报交给
timer.start:脚本注册完就结束,固件自动接管消息泵(C 侧)。不要自己补主循环。 json.decode用pcall包住:云端可能下发非 JSON 内容(比如空串),不捕获会让回调抛错、触发 Lua 状态机重建。
四、验证方法
- 下载脚本并重启设备,日志应出现
启动完成:…与MQTT 已连接。 - 每 10 秒出现一条
已上报: {"dev":"lp3232_dev01","temp":25.3,...};用 MQTT 客户端订阅lp3232/dev01/uplink应能收到。 - 向
lp3232/dev01/downlink发布{"relay":1}→ 日志出现已执行下发, relay = 1,Y0 吸合,uplink收到{"ack":"relay_set","value":1,...}。 - 拔掉温控表的 RS485 线 →
online变为 0,温度保持最后值;插回后恢复(验证status在线判断有效)。 - 断网再恢复 → MQTT 自动重连(无需脚本干预),上报自行恢复。
五、错误写法 vs 正确写法对照
下面是同一需求被写成阻塞主循环的样子 —— 这正是最常见的错误,设备会表现为「MQTT 显示已连接,但收不到任何下发、也不上报」:
lua
-- ❌ 错误:阻塞主循环,消息泵被掐死
mqtt.on_message(...) -- 编造的 API,本设备不存在
mqtt.connect() -- 编造的 API,本设备用 mqtt.start(client)
...
while true do
local temp = modbus.read_holding(1, 0, 1) -- 编造的 API
mqtt.publish(PUB_TOPIC, json.encode({temp = temp[1] / 10.0}), 1)
sleep(10000) -- 真阻塞,不泵消息队列
end三处致命问题:
| 问题 | 后果 | 正确做法 |
|---|---|---|
while true do ... sleep(10000) end | sleep 只停住线程、不泵消息队列,定时器 / MQTT / 串口回调全部永不触发 | 周期任务用 timer.start(10000, fn),脚本注册完即结束 |
mqtt.on_message / mqtt.connect / modbus.read_holding / io.write / net.rssi | 这些 API 在本设备上不存在,是凭通用 Lua 习惯编出来的 | 真实 API:mqtt.creat + mqtt.reg(c, mqtt.RECVDATA, fn) + mqtt.start(c);写输出用 plc.y() |
sleep() 裸函数 | 本设备没有裸 sleep,只有 system.sleep(ms)(且主线程里仍然禁用) | 用定时器;协程内才可用 system.sleep |
给 AI 的提示:如果你在写 LPPLC 的 Lua 程序,请只使用本 Wiki 已文档化的 API; 查不到就标
TODO(待确认),不要按其它平台或通用 Lua 的习惯编函数名。
六、注意事项
- 禁止阻塞主循环:
while true+sleep/system.sleep是最常见的致命写法,见第五节。 mqtt.is_linked()返回整数不是布尔:必须写== 1。写成if mqtt.is_linked(c) then在未连接时也成立(Lua 里0为真)。- 浮点/32 位没有属性式写法:
plc.D100只能读 16 位;浮点必须plc.df(100),32 位整数plc.dd(100)。plc.DD100/plc.DF200读出来是 nil 且不报错。 - 浮点字序:读出来是几万这种离谱值,改
set_float_abcd(依次试CDAB→BADC→ABCD→DCBA)。注意set_32_abcd对FLOAT通道无效,两者是独立设置。 - 组合地址从 1 开始:
400001= 保持寄存器偏移 0;分离形式add_channel(4, 0, ...)从 0 开始,两种不要混用。 mqtt.unsubscribe在 2026-08-16 之前的固件上会抛错(主题参数下标写错一位);之后的固件正常。不确定的先pcall试一次,或在RECVDATA里对不关心的主题return忽略。mqtt.publish未连接时返回 nil(不是 false),判断要写if not mqtt.publish(...) then。- 轮询启动后不要再手动
com.send,会打乱报文时序。