Modbus RTU CO2 监测:MQTT、InfluxDB 与 Discord 告警
本示例演示如何使用 CycBox 将 Senseair S8 NDIR CO2 传感器接入现代可观测性技术栈。我们每 2 秒通过 Modbus RTU 轮询一次传感器,将读数发布到本地 MQTT Broker 和 InfluxDB v3,并在 CO2 浓度超标时通过 ntfy 和 Discord Webhook 发送告警。

本示例实现的功能
- 轮询 Senseair S8 寄存器:自动读取 CO2 浓度、系统状态及设备识别信息(序列号/固件版本)。
- MQTT JSON 发布:将实时 CO2 数据推送至
cycbox/sensor/co2主题。 - InfluxDB v3 集成:将高精度时序数据写入 InfluxDB,用于长期趋势分析。
- 多通道告警:当 CO2 浓度超过 1000 ppm 时,同时向 ntfy 和 Discord 发送高优先级通知。
- 迟滞逻辑:需要浓度降至 700 ppm 以下才解除告警状态,避免告警风暴。
端到端数据流
下图展示了一次 CO2 采样从物理传感器到监控与告警平台的完整传输路径。
设备与串口协议
Senseair S8 是一款微型 NDIR CO2 传感器,通过 3.3V CMOS UART 接口作为 Modbus RTU 从站运行。
- 接口参数:9600 bps,8 位数据位,无校验位,1 位停止位(传感器发送 2 位停止位)。
- 供电:4.5V–5.25V DC。注意: 在 2 秒灯泡循环期间峰值电流可达 300 mA(来源:PSP103.pdf)。
- Modbus 地址:默认范围 1–247。本示例使用
254(0xFE),S8 将其视为广播地址,在点对点配置中会响应任意请求(来源:TDE2067.pdf)。
下表列出了本示例使用的主要 Modbus 输入寄存器。
| 线路地址 | 名称 | 权限 | 单位 / 说明 |
|---|---|---|---|
0x0000 | MeterStatus | RO | 系统健康状态(0x0000 = 正常) |
0x0003 | Space CO2 | RO | CO2 浓度,单位 ppm |
0x001C | FW Version | RO | 高字节:主版本,低字节:子版本 |
0x001D | Sensor ID Hi | RO | 序列号高 16 位 |
0x001E | Sensor ID Lo | RO | 序列号低 16 位 |
CycBox 配置
本示例需要三个连接:传感器的物理串口链路、连接 MQTT Broker 的客户端,以及用于内部路由的本地 MQTT 服务端。

连接 ID 映射
Lua 脚本通过这些 ID 将消息路由到正确的传输层。
| connection_id | 角色 | 传输层 | 编解码器 |
|---|---|---|---|
| 0 | 传感器链路 | serial_port_transport | modbus_rtu_codec |
| 1 | 上行 MQTT | mqtt_transport | timeout_codec |
| 2 | 本地 Broker | mqtt_server_transport | timeout_codec |
连接配置详情
[
{
"app": {
"app_transport": "serial_port_transport",
"app_codec": "modbus_rtu_codec",
"app_transformer": "disable_transformer"
},
"serial_port_transport": {
"serial_port_transport_port": "/dev/ttyACM0",
"serial_port_transport_baud_rate": 9600
},
"modbus_rtu_codec": {
"with_receive_timeout": 20
}
},
{
"app": {
"app_transport": "mqtt_transport",
"app_codec": "timeout_codec"
},
"mqtt_transport": {
"mqtt_transport_broker_url": "mqtt://localhost:1883",
"mqtt_transport_client_id": "cycbox-s8-monitor"
}
}
]
Lua 脚本流程说明
脚本管理传感器的完整生命周期:启动时查询设备信息,之后每 2 秒轮询一次数据。
-- Senseair S8 CO2 传感器监测脚本
-- 通过 Modbus RTU 串口连接 Senseair S8 CO2 传感器、MQTT 客户端和 MQTT Broker。
-- 经由串口 Modbus RTU 轮询 Senseair S8 CO2 传感器,将读数发布到 MQTT 和 InfluxDB v3,
-- 并在 CO2 超过阈值时发送 ntfy/Discord 告警。
--
-- 设备:Senseair S8 CO2 传感器(Modbus RTU 从站)
-- 从站地址:1–247;0xFE = 广播"任意传感器"(点对点测试)
-- 通信参数:9600 波特,8 位数据位,无校验位,1 位停止位(TX 端 2 位)
-- 响应超时:最大 180 ms;轮询间隔:2 s(灯泡循环周期)
-- 单次最多读 8 个寄存器,最大包长 39 字节(含 CRC)
--
-- 输入寄存器映射(功能码 0x04,0-based 线路地址):
-- 0x0000 MeterStatus RO 系统故障标志(0x0000 = 正常)
-- Bit 0: ERR_FATAL
-- Bit 1: ERR_OFFSET_REG
-- Bit 2: ERR_ALGORITHM
-- Bit 3: ERR_OUTPUT
-- Bit 4: ERR_SELF_DIAG
-- Bit 5: ERR_OUT_OF_RANGE
-- Bit 6: ERR_MEMORY
-- 0x0001 AlarmStatus RO 告警标志(保留)
-- 0x0002 Output Status RO Bit 0: ALARM_OUT(反逻辑,开集电极)
-- Bit 1: PWM_OUT(1 = 全功率输出)
-- 0x0003 Space CO2 RO 测量的 CO2 浓度(ppm)
-- 0x0019 Sensor Type ID Hi RO 设备类型 ID 高 16 位
-- 0x001A Sensor Type ID Lo RO 设备类型 ID 低 8 位(位于高字节)
-- 0x001B Memory Map Ver RO 内存映射结构版本
-- 0x001C FW Version RO 固件主版本(bits 15:8). 子版本(bits 7:0)
-- 0x001D Sensor ID High RO 序列号高 16 位
-- 0x001E Sensor ID Low RO 序列号低 16 位
--
-- 保持寄存器映射(功能码 0x03 读 / 0x06 写):
-- 0x0000 Acknowledgement R/W 校准完成标志
-- Bit 5: ACK_BG_CAL, Bit 6: ACK_ZERO_CAL
-- 0x0001 Special Command WO 0x7C06 = 背景校准, 0x7C07 = 零点校准
-- 0x001F ABC Period R/W 自动基线校正间隔(小时;0 = 暂停)
local SLAVE_ADDR = 254
local POLL_MS = 2000
local last_poll_ms = 0
-- 外部服务配置
local MQTT_CONN_ID = 1
local MQTT_TOPIC = "cycbox/sensor/co2"
local INFLUX_URL = get_env("INFLUX_URL") or "http://localhost:8181"
local INFLUX_TOKEN = get_env("INFLUX_TOKEN") or "<REDACTED>"
local INFLUX_DB = "cycbox"
local NTFY_TOPIC = "cycbox_alerts"
local DISCORD_WEBHOOK = get_env("DISCORD_WEBHOOK") or "<REDACTED>"
local CO2_THRESHOLD_HIGH = 1000
local CO2_THRESHOLD_LOW = 700
local is_co2_high = false
function on_start()
log("info", "启动 Senseair S8 Modbus 脚本,已启用 MQTT、InfluxDB 和告警功能。")
-- 启动时查询设备信息:从 0x0019 起读取 6 个寄存器
modbus_rtu_read_input_registers(SLAVE_ADDR, 0x0019, 6, 100, 0)
end
function on_timer(now_ms)
-- 每 POLL_MS(2 秒)轮询一次传感器数值
if now_ms - last_poll_ms >= POLL_MS then
-- 查询传感器活跃数据:从 0x0000 起读取 4 个寄存器
modbus_rtu_read_input_registers(SLAVE_ADDR, 0x0000, 4, 0, 0)
last_poll_ms = now_ms
end
end
function on_receive()
if message.connection_id ~= 0 then return false end
local modified = false
-- 1. 解析传感器活跃数值
local co2 = message:get_value(string.format("modbus_rtu_%d:input_0003", SLAVE_ADDR))
if co2 then
message:add_int_value("CO2_ppm", co2)
modified = true
-- 发布到 MQTT
local mqtt_payload = string.format('{"co2_ppm": %d}', co2)
mqtt_publish(MQTT_TOPIC, mqtt_payload, 0, false, 0, MQTT_CONN_ID)
-- 写入 InfluxDB v3
local line_data = string.format("senseair_s8 co2_ppm=%d", co2)
influxdb_write_v3_async(INFLUX_URL, INFLUX_TOKEN, INFLUX_DB, line_data, "auto", true, false)
-- 告警逻辑
if co2 > CO2_THRESHOLD_HIGH and not is_co2_high then
is_co2_high = true
local alert_msg = string.format("CO2 浓度告警!当前浓度 %d ppm", co2)
log("warn", alert_msg)
ntfy_send_async({topic = NTFY_TOPIC, message = alert_msg, title = "CO2 告警", priority = "high"})
discord_send_async(DISCORD_WEBHOOK, alert_msg)
elseif co2 <= CO2_THRESHOLD_LOW and is_co2_high then
is_co2_high = false
local recovery_msg = string.format("CO2 浓度已恢复正常,当前浓度 %d ppm", co2)
log("info", recovery_msg)
ntfy_send_async({topic = NTFY_TOPIC, message = recovery_msg, title = "CO2 正常", priority = "default"})
discord_send_async(DISCORD_WEBHOOK, recovery_msg)
end
end
local status = message:get_value(string.format("modbus_rtu_%d:input_0000", SLAVE_ADDR))
if status then
message:add_int_value("MeterStatus", status)
if status ~= 0 then
local fatal = bit.band(status, 0x01)
local out_of_range = bit.band(status, 0x20)
if fatal > 0 then log("error", "传感器致命错误") end
if out_of_range > 0 then log("warn", "传感器超出量程") end
end
modified = true
end
local out_status = message:get_value(string.format("modbus_rtu_%d:input_0002", SLAVE_ADDR))
if out_status then
local alarm = (bit.band(out_status, 0x01) > 0)
message:add_bool_value("Alarm_Active", alarm)
modified = true
end
-- 2. 解析启动时的设备信息
local fw = message:get_value(string.format("modbus_rtu_%d:input_001C", SLAVE_ADDR))
if fw then
local main_ver = bit.rshift(fw, 8)
local sub_ver = bit.band(fw, 0xFF)
message:add_string_value("Firmware_Version", string.format("%d.%d", main_ver, sub_ver))
modified = true
end
local id_hi = message:get_value(string.format("modbus_rtu_%d:input_001D", SLAVE_ADDR))
local id_lo = message:get_value(string.format("modbus_rtu_%d:input_001E", SLAVE_ADDR))
if id_hi and id_lo then
local sensor_id = id_hi * 65536 + id_lo
message:add_int_value("Sensor_ID", sensor_id)
modified = true
end
local type_hi = message:get_value(string.format("modbus_rtu_%d:input_0019", SLAVE_ADDR))
local type_lo = message:get_value(string.format("modbus_rtu_%d:input_001A", SLAVE_ADDR))
if type_hi and type_lo then
-- 类型 ID 高 16 位来自 0x0019,低 8 位来自 0x001A 的高字节
local type_id = type_hi * 256 + bit.rshift(type_lo, 8)
message:add_int_value("Sensor_Type_ID", type_id)
modified = true
end
return modified
end
下游服务协议
MQTT
- 主题:
cycbox/sensor/co2 - 载荷格式:
{
"co2_ppm": 450
}
InfluxDB v3
- 测量名称:
senseair_s8 - 字段:
co2_ppm(整型) - Line Protocol 示例:
senseair_s8 co2_ppm=450
告警(ntfy 与 Discord)
- ntfy:告警期间以
priority="high"发布到cycbox_alerts主题。 - Discord:通过 Webhook 以纯文本告警字符串推送。
告警逻辑
为了防止 CO2 浓度在阈值附近波动时产生重复告警,我们引入了**迟滞(Hysteresis)**机制。
下表定义了 CO2 告警的状态转换规则。
| 当前状态 | 条件 | 新状态 | 动作 |
|---|---|---|---|
空闲 | CO2 > 1000 ppm | 告警 | 发送 Discord/ntfy 告警 |
告警 | CO2 < 1001 ppm 且 > 700 ppm | 告警 | 无操作(迟滞窗口) |
告警 | CO2 <= 700 ppm | 空闲 | 发送恢复通知 |
运维注意事项
- 环境变量:运行脚本前必须设置
INFLUX_URL、INFLUX_TOKEN和DISCORD_WEBHOOK。 - 频率限制:Discord Webhook 有速率限制。脚本仅在状态发生变化时发送告警,而非每次 2 秒轮询都触发,从而规避此限制。
- 传感器预热:S8 需要约 2 分钟达到 T90 精度(来源:PSP103.pdf)。初期读数可能略有偏差。
复现此示例
- 硬件:通过 USB 转 RS485 适配器(或直接 UART,前提是宿主机为 3.3V 兼容)连接 Senseair S8 传感器。
- 接线:确保传感器与适配器之间共地。
- 配置:将上述 JSON 配置粘贴到 CycBox 中。
- 环境变量:在 CycBox 环境变量中填入您的密钥信息。
- 部署:将 Lua 脚本上传到引擎。
常见问题
为什么脚本使用从站地址 254?
地址 254(0xFE)是 Senseair S8 的 Modbus 广播地址,无论传感器配置了哪个 ID,都可以与任意已连接的单个传感器通信。这简化了单传感器部署时的配置流程。
如何修改 CO2 告警阈值?
修改 Lua 脚本中的 CO2_THRESHOLD_HIGH 和 CO2_THRESHOLD_LOW 变量,即可设置告警触发值与恢复阈值。
能否直接从 USB-RS485 适配器为传感器供电?
仅当适配器能在 NDIR 灯泡循环期间提供 300mA 峰值电流时才可以;否则需要外部 5V 供电。电流不足通常会导致 Modbus 校验和错误或设备重启。
为什么测量间隔设为 2 秒?
Senseair S8 每 2 秒完成一次内部测量,与其红外灯泡循环周期一致;以更高频率轮询不会获得新数据。
常见问题与建议
- 逻辑电平:S8 使用 3.3V CMOS 逻辑。未经电平转换直接连接 5V UART 可能损坏传感器的 Rx/Tx 引脚。
- ABC(自动基线校正):默认情况下,S8 假设 8 天内测得的最低 CO2 浓度为 400 ppm(新鲜空气)。若传感器持续部署在无新鲜空气的全天候占用空间,最终将产生漂移,导致读数偏低不准确。
- 数据持久化:在启动前请确保 InfluxDB 中已存在
cycbox数据桶,否则异步写入将静默失败。