PMS9103M 空气质量监测器与多渠道通知
概览
本示例展示了如何解析 PMS9103M 颗粒物传感器的数据,并在 PM2.5 浓度超过安全阈值时,通过多个通知渠道发送警报。它展示了 CycBox 的以下功能:
- 基于帧的协议解码 —— 使用内置的帧编解码器(frame codec),支持前缀、长度字段和校验和验证
- 二进制负载解析 —— 从原始传感器数据中提取多个 uint16 大端(big-endian)数值
- 多渠道通知 —— 同时通过 Discord webhook、ntfy 推送通知和 SMTP 邮件发送警报
- 基于滞后(Hysteresis)的警报 —— 使用高/低双阈值来避免通知频繁波动
这是环境监测中的一个常见模式,即需要根据空气质量数据触发跨多个通信渠道的实时警报。
场景
一个 PMS9103M 颗粒物传感器通过串口(例如 /dev/ttyUSB0)连接,并以 9600 波特率持续流式传输数据帧。要求如下:
- 使用前缀检测、长度字段解析和校验和验证来接收并验证传感器数据帧
- 从二进制负载中解析所有 PM 浓度值(PM1.0, PM2.5, PM10)和颗粒计数
- 当 PM2.5 超过 75 ug/m3 时,通过 Discord、ntfy 和邮件发送警报
- 当 PM2.5 降至 35 ug/m3 以下时,发送恢复通知
- 使用滞后机制防止数值在阈值附近波动时重复发送通知
PMS9103M 帧格式
| 字段 | 大小 | 描述 |
|---|---|---|
| 前缀 | 2 字节 | 0x42 0x4D ("BM") |
| 长度 | 2 字节 | uint16 大端,负载 + 校验和的长度 |
| 负载 | 26 字节 | 传感器数据 (13 x uint16 大端值) |
| 校验和 | 2 字节 | 前缀 + 长度 + 负载的 Sum16 大端和 |
PMS9103M 负载结构
所有数值均为 uint16 大端:
| 字节偏移 | 描述 |
|---|---|
| 0-1 | PM1.0 浓度 (CF=1),单位 ug/m3 |
| 2-3 | PM2.5 浓度 (CF=1),单位 ug/m3 |
| 4-5 | PM10 浓度 (CF=1),单位 ug/m3 |
| 6-7 | PM1.0 浓度 (大气环境下),单位 ug/m3 |
| 8-9 | PM2.5 浓度 (大气环境下),单位 ug/m3 |
| 10-11 | PM10 浓度 (大气环境下),单位 ug/m3 |
| 12-13 | 每 0.1L 空气中 >0.3um 的颗粒数 |
| 14-15 | 每 0.1L 空气中 >0.5um 的颗粒数 |
| 16-17 | 每 0.1L 空气中 >1.0um 的颗粒数 |
| 18-19 | 每 0.1L 空气中 >2.5um 的颗粒数 |
| 20-21 | 每 0.1L 空气中 >5.0um 的颗粒数 |
| 22-23 | 每 0.1L 空气中 >10um 的颗粒数 |
CF=1 数值是工厂校准的标准颗粒浓度。大气环境数值针对环境条件进行了修正,通常用于空气质量评估。
配置
CycBox 配置了一个串口连接,使用帧编解码器处理 PMS9103M 二进制协议:
{
"version": "1.8.1",
"name": "带通知功能的 PMS9103M 空气质量监测器",
"description": "解析 PMS9103M 传感器数据,并通过 Discord、ntfy 和 SMTP 发送 PM2.5 警报",
"configs": [
{
"app": {
"app_transport": "serial",
"app_codec": "frame_codec",
"app_transformer": "disable",
"app_encoding": "UTF-8"
},
"serial": {
"serial_port": "/dev/ttyUSB0",
"serial_baud_rate": 9600,
"serial_data_bits": 8,
"serial_parity": "none",
"serial_stop_bits": "1",
"serial_flow_control": "none"
},
"frame_codec": {
"frame_codec_prefix": "42 4d",
"frame_codec_header_size": 0,
"frame_codec_tailer_length": 0,
"frame_codec_suffix": "",
"frame_codec_length_mode": "u16_be",
"frame_codec_fixed_payload_size": 32,
"frame_codec_length_meaning": "payload_checksum",
"frame_codec_checksum_algo": "sum16_be",
"frame_codec_checksum_scope": "prefix_header_length_payload"
}
}
]
}
帧编解码器设置
| 参数 | 值 | 描述 |
|---|---|---|
frame_codec_prefix | 42 4d | PMS "BM" 起始字节 |
frame_codec_length_mode | u16_be | 2 字节大端长度字段 |
frame_codec_length_meaning | payload_checksum | 长度包含负载 + 校验和字节 |
frame_codec_checksum_algo | sum16_be | Sum16 大端校验和 |
frame_codec_checksum_scope | prefix_header_length_payload | 校验和覆盖除校验和自身外的整个帧 |
帧编解码器会自动处理所有成帧工作 —— 前缀检测、基于长度的提取和校验和验证。Lua 脚本仅接收经过验证的 26 字节负载。
Lua 脚本逻辑
该脚本在 on_receive() 中解析传感器数据,并使用基于滞后的阈值检查来触发通知。
通知配置
-- PM2.5 阈值 (ug/m3)
local PM25_HIGH_THRESHOLD = 75 -- 当 PM2.5 超过此值时发送警报
local PM25_LOW_THRESHOLD = 35 -- 当 PM2.5 降至此值以下时发送恢复通知
-- Discord webhook
local DISCORD_WEBHOOK_URL = "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"
local DISCORD_USERNAME = "CycBox 空气监测器"
-- ntfy 推送通知
local NTFY_TOPIC = "cycbox_air_quality"
-- SMTP 邮件
local SMTP_CONFIG = {
server = "smtp.gmail.com",
port = 587,
tls = "starttls",
username = "your-email@gmail.com",
password = "your-app-password",
from = "your-email@gmail.com",
to = "recipient@example.com",
}
运作原理
1. 负载解析 (on_receive)
function on_receive()
local payload = message.payload
if #payload ~= 26 then
return false
end
-- 解析 PM 浓度 (CF=1),单位 ug/m3
local pm1_0_cf1 = read_u16_be(payload, 1)
local pm2_5_cf1 = read_u16_be(payload, 3)
local pm10_cf1 = read_u16_be(payload, 5)
-- 解析 PM 浓度 (大气环境下),单位 ug/m3
local pm1_0_atm = read_u16_be(payload, 7)
local pm2_5_atm = read_u16_be(payload, 9)
local pm10_atm = read_u16_be(payload, 11)
-- 解析每 0.1L 空气中的颗粒数
local particles_0_3um = read_u16_be(payload, 13)
local particles_0_5um = read_u16_be(payload, 15)
-- ... (共 6 个颗粒大小区间)
end
- 验证负载长度(帧编解码器剥离前缀、长度和校验和后应为 26 字节)
- 使用
read_u16_be(payload, offset)提取所有 13 个 uint16 大端值 - CF=1 是出厂校准值;大气环境值是经过环境修正的值
2. UI 图表展示
message:add_int_value("PM1.0-CF1", pm1_0_cf1)
message:add_int_value("PM2.5-CF1", pm2_5_cf1)
message:add_int_value("PM10-CF1", pm10_cf1)
message:add_int_value("PM2.5-ATM", pm2_5_atm)
-- ... 所有 12 个值都已添加,用于实时图表绘制
- 所有解析后的数值都通过
message:add_int_value()添加到消息元数据中,用于 UI 实时图表绘制


3. 基于滞后的警报
local alert_state = "normal"
if alert_state == "normal" and pm2_5_atm > PM25_HIGH_THRESHOLD then
alert_state = "alert"
send_high_alert(pm2_5_atm)
elseif alert_state == "alert" and pm2_5_atm < PM25_LOW_THRESHOLD then
alert_state = "normal"
send_recovery_alert(pm2_5_atm)
end
- 使用两个阈值(高=75,低=35)创建一个滞后区间
- 只有当 PM2.5 升至 75 ug/m3 以上时才触发一次警报
- 只有当 PM2.5 降至 35 ug/m3 以下时才触发一次恢复
- 当数值在两个阈值之间波动时,不会重复发送通知
4. 多渠道通知
每条警报都同时通过三个渠道发送:
-- Discord webhook
discord_send_async(DISCORD_WEBHOOK_URL, msg, DISCORD_USERNAME, nil)
-- ntfy 推送通知
ntfy_send_async({
topic = NTFY_TOPIC,
message = msg,
title = "PM2.5 高浓度警报",
priority = "high",
tags = "warning,skull",
})
-- SMTP 邮件
smtp_send_async({
server = SMTP_CONFIG.server,
port = SMTP_CONFIG.port,
tls = SMTP_CONFIG.tls,
username = SMTP_CONFIG.username,
password = SMTP_CONFIG.password,
from = SMTP_CONFIG.from,
to = SMTP_CONFIG.to,
subject = string.format("PM2.5 警报: %d μg/m³", pm2_5_value),
text = msg,
})
- 所有三个通知均异步发送(带有
_async后缀),因此不会阻塞传感器数据处理 - Discord 使用带有自定义用户名的 webhook URL
- ntfy 支持优先级和表情标签
- SMTP 支持用于安全邮件传输的 STARTTLS
警报流程图
PM2.5 数值
|
| ──── 75 ug/m3 ──── 高阈值 ──── 触发警报 ────→ "alert" 状态
| │
| (此区间内不重复发送通知) │
| │
| ──── 35 ug/m3 ──── 低阈值 ──── 触发恢复 ────→ "normal" 状态
|
通知示例
高浓度警报消息:
PM2.5 警报:82 ug/m3 (阈值: 75 ug/m3)。空气质量不健康!
恢复消息:
PM2.5 恢复:28 ug/m3 (阈值: 35 ug/m3)。空气质量已恢复正常。
查看 GitHub 上的完整示例脚本。