Skip to content

综合例程:Modbus 温度采集 + MQTT 上报 + 云端下发控制继电器 ​

一个端到端的完整工程:RS485 读温控表 → 定时 MQTT 上报 → 订阅云端下发 → 控制继电器 → 回 ACK。 这是物联网网关最典型的形态,也是 AI 最容易写错的一种(见第五节)。

端到端数据流:温控表 → com 轮询线程 → D100 → 定时器 → MQTT → 云端,云端下发反向回写 Y0


一、场景与软元件分配 ​

现场有一台 RS485 温控表(Modbus RTU 从站地址 1,温度存在保持寄存器 40001,浮点), 需要每 10 秒把温度发到云端,并接受云端下发指令控制本地继电器 Y0。

位置地址说明
温控表保持寄存器40001(浮点,2 个寄存器)温度原始值
本地 PLCD100 / D101采集落点(浮点占用两个 D)
本地 PLCY0被控继电器
从站通信状态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 轮询线程驱动,上报由定时器驱动,脚本到此结束")

三、逐段要点 ​

  1. 采集不用自己发报文:com_modbus 只描述「从站有哪些通道」,com.start 起的轮询线程会按 set_time_lunxun 的间隔自动遍历,值直接落到 D 寄存器 / data 对象,CRC 和帧时序都由固件处理。脚本里不要再对同一串口手动 send。
  2. status 参数建议传:com_modbus.creat 的第 3 参把通信状态回写到 data 对象,用来区分「从站掉线」和「数值确实是 0」。不传的话采不到数据时无从判断。
  3. MQTT 客户端必须长期持有:mqttc 用全局变量(或被长生命周期变量引用)。被 GC 回收会直接断连。
  4. RECVDATA 要在 start 之前注册,否则可能漏掉早期消息;订阅放在 CONNECTED 回调里,因为未连接时 subscribe 会失败。
  5. 周期上报交给 timer.start:脚本注册完就结束,固件自动接管消息泵(C 侧)。不要自己补主循环。
  6. json.decode 用 pcall 包住:云端可能下发非 JSON 内容(比如空串),不捕获会让回调抛错、触发 Lua 状态机重建。

四、验证方法 ​

  1. 下载脚本并重启设备,日志应出现 启动完成:… 与 MQTT 已连接。
  2. 每 10 秒出现一条 已上报: {"dev":"lp3232_dev01","temp":25.3,...};用 MQTT 客户端订阅 lp3232/dev01/uplink 应能收到。
  3. 向 lp3232/dev01/downlink 发布 {"relay":1} → 日志出现 已执行下发, relay = 1,Y0 吸合,uplink 收到 {"ack":"relay_set","value":1,...}。
  4. 拔掉温控表的 RS485 线 → online 变为 0,温度保持最后值;插回后恢复(验证 status 在线判断有效)。
  5. 断网再恢复 → 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) endsleep 只停住线程、不泵消息队列,定时器 / 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,会打乱报文时序。

七、相关文档 ​

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