Skip to main content

甲醛监测:Home Assistant 与 TimescaleDB 集成

本示例演示如何将 WZ-S 甲醛(CH₂O)检测模块接入现代监控体系。通过 CycBox Lua 引擎,我们解析传感器私有的 9 字节 UART 协议,将实时状态推送至 Home Assistant REST API,将高精度数据归档到 TimescaleDB 超级表,并在空气质量超过阈值时触发邮件告警。

功能概述

本集成将原始串口传感器转变为一个可管理的环境监测节点:

  • 协议处理:解析 9 字节定长帧,包含自定义 LRC 变体校验和验证。
  • 工作模式控制:自动将传感器从默认的"主动上报"模式切换到"问答(QA)"模式,实现按需轮询。
  • 多路数据分发
    • Home Assistant:以 ppb 和 µg/m³ 为单位更新 sensor.wz_s_formaldehyde 实体。
    • TimescaleDB:异步写入超级表,支持长期趋势分析。
    • SMTP 告警:当甲醛浓度超过 80 ppb 时触发高优先级邮件通知,低于 50 ppb 时发送恢复通知。
  • 迟滞逻辑:要求浓度大幅下降后才清除告警状态,避免告警抖动。

端到端数据流

下图描述了一次测量数据从燃料电池传感器到可视化及告警层的完整传播路径。

设备与线路协议

WZ-S 甲醛检测模块是一款基于燃料电池原理、通过 UART 提供标准化数字输出的传感器。

  • 电气接口:UART,9600 波特,8N1。逻辑电平为 3.3V,但供电电压(Vin)需要 5V 至 7V。
  • 物理层:传感器使用 9 字节定长帧。根据数据手册,需要不超过 3 分钟的预热时间才能获得准确读数。

下表描述了传感器在 QA 模式下响应帧(命令 0x86)的 9 字节结构。

字节字段类型单位说明
0起始位uint8-固定值 0xFF
1命令字uint8-0x86(QA 响应)
2浓度高位uint8µg/m³浓度高字节
3浓度低位uint8µg/m³浓度低字节
4-5保留--固定 0x00
6浓度高位uint8ppb浓度高字节
7浓度低位uint8ppb浓度低字节
8校验和uint8-(NOT(Sum(字节 1..7))) + 1

CycBox 配置

CycBox 自动处理帧层,使 Lua 脚本只需关注 7 字节有效载荷(不含 0xFF 前缀和校验字节)。

CycBox configuration

连接:串口

串口连接指向传感器所连接的本地 UART 端口。

{
"serial_port_transport": {
"serial_port_transport_port": "/dev/ttyACM0",
"serial_port_transport_baud_rate": 9600,
"serial_port_transport_data_bits": 8,
"serial_port_transport_parity": "none",
"serial_port_transport_stop_bits": "1"
}
}

编解码器:帧编解码器

frame_codec 针对 WZ-S 私有格式进行了调优。通过定义前缀和校验算法,引擎在数据到达 Lua 环境之前即丢弃格式错误的帧。

{
"frame_codec": {
"frame_codec_length_mode": "fixed",
"frame_codec_prefix": "FF",
"frame_codec_fixed_payload_size": 7,
"frame_codec_checksum_algo": "lrc",
"frame_codec_checksum_scope": "payload"
}
}

Lua 流水线详解

Lua 脚本负责管理传感器生命周期与数据分发。


--
-- TimescaleDB 初始化(首次运行前执行):
-- CREATE TABLE sensor_readings (
-- time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- sensor TEXT NOT NULL,
-- formaldehyde_ppb INTEGER,
-- formaldehyde_ugm3 INTEGER
-- );
-- SELECT create_hypertable('sensor_readings', 'time', if_not_exists => TRUE);
-- -- 可选:为高效的单传感器查询添加索引
-- CREATE INDEX ON sensor_readings (sensor, time DESC);

local HA_URL = get_env("HA_URL") or "http://localhost:8123/api/states/sensor.wz_s_formaldehyde"
local HA_TOKEN = get_env("HA_TOKEN") or "YOUR_LONG_LIVED_ACCESS_TOKEN"
local TS_CONN = get_env("TS_CONN") or "host=localhost port=15432 dbname=cycbox user=postgres password=xxxxxx sslmode=disable"

local POLL_INTERVAL = 5000 -- 每 5 秒轮询一次
local timer_counter = 0
local db_connected = false

-- 告警配置
local ALARM_HIGH_PPB = 80
local ALARM_LOW_PPB = 50

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",
}

local is_ppb_high = false

local function send_alarm_email(ppb_val, state)
local subject, msg
if state == "HIGH" then
subject = string.format("告警:甲醛浓度过高(%d ppb)", ppb_val)
msg = string.format("甲醛浓度已超过高阈值(%d ppb),当前值:%d ppb。", ALARM_HIGH_PPB, ppb_val)
log("warn", msg)
else
subject = string.format("恢复:甲醛浓度已恢复正常(%d ppb)", ppb_val)
msg = string.format("甲醛浓度已降至低阈值(%d ppb)以下,当前值:%d ppb。", ALARM_LOW_PPB, ppb_val)
log("info", msg)
end

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 = subject,
text = msg,
})
end

function on_start()
-- 连接 TimescaleDB
local ok, err = timescaledb_connect(TS_CONN, 3)
if ok then
db_connected = true
log("info", "已连接到 TimescaleDB")
else
log("error", "TimescaleDB 连接失败:" .. (err or "未知错误"))
end

-- 切换到问答(QA)模式
-- 帧编解码器会自动附加 0xFF 前缀和 1 字节校验和。
local qa_mode_cmd = string.char(0x01, 0x78, 0x41, 0x00, 0x00, 0x00, 0x00)
send_after(qa_mode_cmd, 100, 0)
log("info", "WZ-S 脚本已启动,已切换至 QA 模式。")
end

function on_receive()
-- 仅处理来自连接 0 的数据
if message.connection_id ~= 0 then return false end
-- frame_codec 已自动验证 LRC 校验和
if not message.checksum_valid then return false end

-- 有效载荷不含 0xFF 前缀和 1 字节校验尾
local payload = message.payload
if not payload or #payload < 7 then return false end

local cmd = string.byte(payload, 1)
local ppb = nil
local ugm3 = nil

if cmd == 0x17 then
-- 主动上报模式数据(兼容处理)
ppb = string.byte(payload, 4) * 256 + string.byte(payload, 5)
log("info", string.format("主动上报 - 甲醛:%d ppb", ppb))

elseif cmd == 0x86 then
-- 问答模式数据响应
ugm3 = string.byte(payload, 2) * 256 + string.byte(payload, 3)
ppb = string.byte(payload, 6) * 256 + string.byte(payload, 7)
log("info", string.format("QA 读取 - 甲醛:%d ppb,%d ug/m3", ppb, ugm3))

else
-- 忽略确认消息(如 0x78)或未知命令
return false
end

-- 将解析值添加到消息上下文
if ppb then message:add_int_value("formaldehyde_ppb", ppb) end
if ugm3 then message:add_int_value("formaldehyde_ugm3", ugm3) end

if ppb then
if ppb > ALARM_HIGH_PPB and not is_ppb_high then
is_ppb_high = true
send_alarm_email(ppb, "HIGH")
elseif ppb <= ALARM_LOW_PPB and is_ppb_high then
is_ppb_high = false
send_alarm_email(ppb, "NORMAL")
end

-- 通过 HTTP POST 将数值发送到 Home Assistant
local ugm3_attr = ugm3 or 0
local ha_body = string.format('{"state": %d, "attributes": {"unit_of_measurement": "ppb", "device_class": "volatile_organic_compounds", "ugm3": %d}}', ppb, ugm3_attr)
local headers = {
["Authorization"] = "Bearer " .. HA_TOKEN,
["Content-Type"] = "application/json"
}
http_post(HA_URL, ha_body, headers)
end

-- 异步写入 TimescaleDB
if db_connected and ppb then
local ugm3_val = ugm3 or 0
timescaledb_insert_async("sensor_readings",
{"sensor", "formaldehyde_ppb", "formaldehyde_ugm3"},
{"wz_s", ppb, ugm3_val})
end

return true
end

function on_timer(now_ms)
timer_counter = timer_counter + 100
if timer_counter >= POLL_INTERVAL then
timer_counter = 0

-- 发送浓度读取命令(QA 模式)
-- 帧编解码器将自动附加 0xFF 前缀和 LRC 校验和。
local read_cmd = string.char(0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00)
send_after(read_cmd, 0, 0)
end
end

function on_stop()
if db_connected then
timescaledb_disconnect()
end
end

下游服务约定

Home Assistant

CycBox 直接将数据推送至 Home Assistant REST API。

  • 端点/api/states/sensor.wz_s_formaldehyde
  • 鉴权:Bearer Token(长期访问令牌)。
  • 载荷结构
{
"state": 12,
"attributes": {
"unit_of_measurement": "ppb",
"device_class": "volatile_organic_compounds",
"ugm3": 15
}
}

TimescaleDB

脚本需要一张名为 sensor_readings 的超级表。

下表定义了甲醛监测数据库的表结构。

字段数据类型说明
timeTIMESTAMPTZ分区键(主时间索引)
sensorTEXT传感器标识符(例如 'wz_s')
formaldehyde_ppbINTEGER测量浓度,单位 ppb
formaldehyde_ugm3INTEGER测量浓度,单位 µg/m³

SMTP(邮件告警)

  • 安全性:端口 587,使用 STARTTLS。
  • 频率:由迟滞逻辑去抖动;仅在状态切换时(超限/恢复)发送邮件。

告警逻辑

为防止浓度在阈值附近震荡时产生"告警抖动",脚本实现了 30 ppb 的迟滞窗口。

事件条件动作
触发告警浓度 > 80 ppb发送"超限"告警邮件
告警持续50 ppb < 浓度 < 80 ppb无操作(维持告警状态)
恢复触发浓度 ≤ 50 ppb发送"恢复正常"邮件

运维注意事项

  • 预热不准确:上电后约 180 秒内,传感器可能报告 0 ppb 或异常值。若用于关键安全决策,自动化流程中应加入 warm_up 标记。
  • 使用环境:避免将传感器暴露于高浓度有机溶剂(如酒精或清洁喷雾)中,这些物质会暂时饱和燃料电池并导致误报。
  • 数据库连接:脚本使用 timescaledb_insert_async。若数据库不可达,记录将在内存中排队;请确保 CycBox 主机有足够内存应对数据库中断。

常见问题

传感器为何在启动后数分钟内一直返回 0 ppb?

WZ-S 采用燃料电池化学反应原理,需要一段稳定时间。数据手册规定,内部电解质达到所需灵敏度需要最长 3 分钟的预热时间。

如何手动验证 WZ-S 校验和?

校验和为 LRC 变体。将字节 1 至 7 求和,进行按位取反,再加 1。若求和为 0x86,执行取反加 1 后,结果应与帧的第 8 字节一致。

QA 模式与主动上报模式有何区别?

主动上报模式(0x40)每秒自动推送一次数据,无需主机请求。QA 模式(0x41)使传感器保持静默,直到收到"读取浓度"命令(0x86),主机可控制轮询频率并降低总线流量。

注意事项与建议

  • 逻辑电平:请使用 3.3V UART 适配器。将 TX/RX 引脚直接连接到 5V 逻辑电路可能损坏传感器控制板。
  • 电源供给:虽然逻辑电平为 3.3V,但传感器的加热器和电化学组件需要至少 5V 供电。电源不稳定会导致明显漂移。
  • 迟滞调优:若您所处环境的本底值在 40~60 ppb 之间,建议将 ALARM_HIGH_PPB 提高至 100,以避免频繁误报。