PetBoxX ESP32 安全冗余机制设计与测试手册
适用固件版本:v1.1.14+
平台:ESP32-S3 / ESP-IDF
场景:孵化保温箱,PTC 加热 + 顶部风扇 + SHT30/40 温湿度传感器
背景与动机
2025 年 5 月 17 日,设备 80B54EE7B22C 在 OTA 升级后出现以下故障:
- LEDC PWM 驱动进入不一致状态 — 顶部风扇 PWM 信号丢失,转速归零
- I2C 驱动进入不一致状态 — SHT30 在 OTA 重启时处于测量中途,SDA 被拉低,所有后续读取超时,
s_sht_data温湿度冻结在 39.80 °C / 42.70 %
由于温度数据冻结,MQTT 的变化抑制逻辑(相同数据不上报)导致云端看不到任何异常,同时顶部风扇已停转,加热器却因控制环仍以旧数据运算而继续工作。
这次改动的目标:任何单点故障都不能导致 PTC 加热器在无人干预的情况下持续工作。
整体架构
┌─────────────────────────────────────────────────────────────┐
│ ESP32 任务层 │
│ │
│ sensor_sht_task (优先级 5) │
│ ├─ 每 1s 读 SHT30/40 │
│ ├─ 成功 → set_temperature() → device_control_mark_alive() │
│ ├─ 失败 5 次 → I2C 完整重初始化 + SHT 软复位 │
│ └─ esp_task_wdt_reset() (每循环) │
│ │
│ safety_monitor_task (优先级 6) ← 比 sensor 高一级 │
│ ├─ 每 1s 检查 device_control_get_last_alive_ms() │
│ ├─ > 10s 未心跳 → device_control_force_heater_off() │
│ ├─ 每 10s MQTT 上报 safety_status │
│ └─ esp_task_wdt_reset() (每循环) │
│ │
│ main_task (优先级 10) │
│ ├─ WiFi/MQTT 状态机 │
│ ├─ 未注册 WDT(可合法长阻塞处理网络事件) │
│ └─ 每 50ms 检查异步 NTP 完成标志 │
│ │
│ ntp_sync_task (一次性, 优先级 4) │
│ └─ obtain_time() 后台运行,不阻塞 main_task │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ ESP-IDF Task WDT │
│ 超时: 60s 触发: 打日志(不 panic) │
│ 注册任务: sensor_sht_task, safety_monitor_task │
│ 排除任务: main_task, MQTT task, NTP task(网络可合法阻塞) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 硬件安全联锁(已有) │
│ fan_rpm_meas < 200 RPM → heat_cmd = 0(control_loop.c) │
└─────────────────────────────────────────────────────────────┘
实现细节
1. 控制循环心跳 API
文件:main/devices/device_control.c / device_control.h
// 每次温控成功 tick 调用(sensor_sht_task 成功读取后)
void device_control_mark_alive(void);
// 返回距上次 mark_alive 的毫秒数(从未调用则返回 0)
int64_t device_control_get_last_alive_ms(void);
实现要点:
- 使用
esp_timer_get_time()(微秒精度,不依赖 RTOS tick) mark_alive()同时会自动清除因ctrl_stall导致的 forced-off 锁存,实现故障自恢复
void device_control_mark_alive(void) {
s_last_alive_us = esp_timer_get_time();
// 自动清除 ctrl_stall 类 forced-off(传感器恢复后自动解锁)
if (s_heater_forced_off &&
s_heater_off_reason[0] == 'c' &&
s_heater_off_reason[1] == 't') {
s_heater_forced_off = false;
s_heater_off_reason[0] = '\0';
}
}
2. 强制断热 API
文件:main/devices/device_control.c / device_control.h
// 立即切断 PTC,并阻止 apply_control_from_loop() 重新启用加热
void device_control_force_heater_off(const char *reason);
// 清除锁存(手动恢复或传感器自恢复后调用)
void device_control_clear_heater_forced_off(void);
bool device_control_is_heater_forced_off(void);
const char *device_control_get_heater_off_reason(void);
关键实现:在 apply_control_from_loop() 入口处插入拦截:
static void apply_control_from_loop(float user_sp_c, float temp_meas_c) {
// 安全联锁:强制断热标志置位时,不允许任何加热
if (s_heater_forced_off) {
triac_delay_us = AC_HALF_CYCLE_US; // 立即关闭 TRIAC
heating_enabled = false;
return; // 跳过所有控制逻辑
}
// ... 正常控制逻辑
}
triac_delay_us 由硬件 ISR(零过检测中断)直接读取,设为 AC_HALF_CYCLE_US(10000 µs)意味着 TRIAC 在整个交流半周期都不触发,等效于立即断开加热。
reason 字符串约定:
| reason | 触发场景 |
|---|---|
ctrl_stall | 温控心跳超时(safety_monitor 检测到) |
ota_start | 固件 OTA 开始前 |
lcd_ota | LCD OTA 开始前 |
test | 手动测试命令 |
3. 安全监督任务
文件:main/petboxX.c
#define SAFETY_CTRL_STALL_MS 10000 // 10s 无心跳 → 触发断热
static void safety_monitor_task(void *arg) {
esp_task_wdt_add(NULL);
vTaskDelay(pdMS_TO_TICKS(15000)); // 等待设备启动稳定
while (1) {
esp_task_wdt_reset();
int64_t alive_ms = device_control_get_last_alive_ms();
if (!device_control_is_heater_forced_off() &&
alive_ms > SAFETY_CTRL_STALL_MS) {
device_control_force_heater_off("ctrl_stall");
// MQTT 告警上报(best-effort)
}
// 每 10s 定期上报安全状态
if (++s_pub_tick >= 10) {
// 发布 safety_status MQTT 消息
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
设计考量:
- 优先级 6,高于 sensor_sht_task(5),确保即使 sensor 任务挤 CPU 也能被调度
- 启动延迟 15s:避免设备刚上电、I2C 还在初始化时就误判"心跳超时"
- 不调用
esp_restart():保温箱场景下,重启会导致 LCD 复位、继电器冲击,等待人工检查更安全
4. I2C 完整重初始化
文件:main/devices/sensor_sht.c
原有逻辑(缺陷):连续 5 次失败仅重置 s_sensor_type,不清理驱动状态。
新逻辑:连续 5 次失败触发完整重初始化:
sensor_sht_i2c_full_restart()
├─ i2c_driver_delete() ← 清理驱动
├─ 16 个 SCL 脉冲 + STOP 条件 ← 物理总线复位
├─ sensor_sht_i2c_master_init() ← 重装驱动
└─ 发送 SHT30 软复位命令 0x30A2 ← 重置传感器 FSM
SHT30 软复位原理:SHT30 内部状态机在上电或复位后从头开始。OTA 重启时如果 SHT30 正在采样(FSM 处于 measurement state),重启后 ESP32 发出的 I2C START 会与 SHT30 状态机预期的序列不一致,导致 NACK 或数据错误。0x30A2 强制 SHT30 退出任何中间状态回到 idle。
5. Task Watchdog 配置
文件:main/petboxX.c
esp_task_wdt_config_t wdt_cfg = {
.timeout_ms = 60 * 1000, // 60s 超时
.idle_core_mask = 0, // 不监控 idle 任务
.trigger_panic = false, // 仅打日志,不触发 panic 重启
};
esp_task_wdt_reconfigure(&wdt_cfg);
为什么 60s 而不是推荐的 8-15s?
Task WDT 是"任务完全冻结"的最后一道防线(如死锁、无限循环)。应用层心跳检测(10s)才是主要的温控异常检测手段。60s 避免 OTA 写 Flash、I2C 总线恢复等合法长操作误触发。
为什么 trigger_panic = false?
保温箱首要目标是安全停止加热,而不是立即重启。safety_monitor_task 在 WDT 触发前(<60s)就已经切断加热器了。WDT 触发时加热器已处于安全状态,此时打日志比 panic 更利于远程诊断。
注册与排除:
| 任务 | WDT 注册 | 原因 |
|---|---|---|
sensor_sht_task | ✅ 注册 | 严格 1s 周期,绝不应长阻塞 |
safety_monitor_task | ✅ 注册 | 严格 1s 周期 |
main_task | ❌ 排除 | 处理 WiFi 事件,可合法长阻塞 |
| MQTT 任务 | ❌ 排除 | 网络 IO 可合法长等待 |
ntp_sync_task | ❌ 排除 | 一次性任务,最多等 10s |
6. OTA 前强制断热
文件:main/devices/petbox_ota.c / petbox_lcd_ota.c
static void ota_task(void *param) {
// 第一步:断热,防止 OTA 期间温控任务被抢占导致热失控
device_control_force_heater_off("ota_start");
ESP_LOGI(TAG, "OTA: heater forced OFF before download");
// ...后续下载、写 Flash、重启
}
OTA 重启后 s_heater_forced_off 不持久化(RAM 变量),新固件启动时自动清零,温控正常恢复。
7. NTP 同步异步化
文件:main/petboxX.c
问题:原来 obtain_time() 在 main_task 的 SYS_RUN 状态处理中同步调用,最多阻塞 10s(10 次重试 × 1s)。期间 WiFi 断连等事件积压在队列无法处理。
修复:独立任务后台运行:
原来:main_task → SYS_RUN → obtain_time() [阻塞 0~10s] → http_server_start()
↑ WiFi 断连事件丢失
现在:main_task → SYS_RUN → http_server_start() ← 立即执行
→ petbox_mqtt_start() ← 立即执行
→ xTaskCreate(ntp_sync_task) ← 后台
↓
[ntp_sync_task] obtain_time() → s_ntp_done=true
↓
[main_task 主循环每 50ms] s_ntp_done → ntp_time_synced=true
WiFi 重连时 s_ntp_running = false,下次连接自动重新发起 NTP 同步。
8. 启动复位原因上报
文件:main/petboxX.c
// app_main 开头读取
esp_reset_reason_t rr = esp_reset_reason();
// 转换为字符串,存入 s_boot_reset_reason
// 每 10s 通过 safety_status MQTT 消息上报
上报字段 boot_reset_reason 可能的值:
| 值 | 含义 |
|---|---|
power_on | 正常上电 |
sw_reset | 软件主动重启(esp_restart()) |
task_wdt | Task Watchdog 触发重启 |
int_wdt | Interrupt Watchdog 触发重启 |
panic | 代码异常(assert、null 解引用等) |
brownout | 欠压保护 |
ext_reset | EN 引脚外部复位 |
MQTT 消息参考
定时上报:safety_status(每 10s)
Topic:petbox/iot/{device_id}/status
{
"event": "safety_status",
"ctrl_alive_ms": 1234,
"heater_forced_off": false,
"heater_off_reason": "",
"boot_reset_reason": "power_on"
}
| 字段 | 含义 |
|---|---|
ctrl_alive_ms | 距上次温控心跳的毫秒数,正常应 < 2000 |
heater_forced_off | 加热器是否被强制关闭 |
heater_off_reason | 关闭原因(ctrl_stall / ota_start / lcd_ota / test) |
boot_reset_reason | 本次启动的复位原因 |
告警上报:safety_alert(触发时立即发送)
{
"event": "safety_alert",
"reason": "ctrl_stall",
"stall_ms": 11234,
"heater_forced_off": true
}
命令响应格式
所有命令通过后端 API 下发:
curl -X POST "http://<server>/api/devices/<device_id>/commands/custom" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"action":"<命令名>"}'
测试步骤
前置准备
- 固件版本 v1.1.14+ 烧录完成
- 设备在线,MQTT 已连接
- 订阅设备 status topic 以实时查看响应:
petbox/iot/{device_id}/status - 获取有效 Bearer Token(由后端
/api/auth/login接口签发)
TEST-01:查询当前安全状态
目的:确认 safety_monitor_task 正常运行,心跳数据正常。
命令:
curl -X POST ".../commands/custom" -d '{"action":"safety_status"}'
预期响应(MQTT):
{
"event": "safety_status_ack",
"ok": true,
"detail": "alive_ms=1200 forced_off=0 reason="
}
验收条件:
alive_ms< 2000(温控每秒心跳一次)forced_off= 0reason为空
TEST-02:模拟温控环卡死(需要 SAFETY_TEST_ENABLED=1)
目的:验证 safety_monitor_task 能在 10s 内检测到心跳停跳并切断加热器。
命令:
curl -X POST ".../commands/custom" -d '{"action":"safety_test_stall"}'
执行后观察:
-
立即(0s):
- 收到
safety_test_stall_ack响应 triac_delay_us被设为AC_HALF_CYCLE_US,加热器立刻停止heater_forced_off = true,reason = "ctrl_stall"
- 收到
-
约 1-2s 内:
- safety_monitor_task 检测到
alive_ms > 10000 - 发送 MQTT
safety_alert:{"event":"safety_alert","reason":"ctrl_stall","stall_ms":11000,"heater_forced_off":true}
- safety_monitor_task 检测到
-
约 10s 内:
- 温度曲线(前端)开始缓慢下降(加热停止,散热中)
注意:此时 sensor_sht_task 仍在正常运行(I2C 读取、心跳仍更新),但因
s_heater_forced_off=true,apply_control_from_loop()入口被拦截,心跳不会自动清除该标志(ctrl_stall自清除逻辑在mark_alive()里,但force_heater_off()是从测试命令触发的,不是真实卡死——所以心跳实际上还在更新,mark_alive()会在下一个 tick 自动清除 forced-off)。如果测试命令立即被心跳自动恢复,说明 sensor 任务运行正常,这是正确行为。若要保持断热状态更长时间观察,可以先
safety_test_stall之后再快速查询safety_status。
TEST-03:手动恢复加热
目的:验证 safety_recover 能清除 forced-off 锁存,恢复正常温控。
命令:
curl -X POST ".../commands/custom" -d '{"action":"safety_recover"}'
预期响应:
{
"event": "safety_recover_ack",
"ok": true,
"detail": "was_forced=1 reason=ctrl_stall"
}
验收条件:
was_forced=1确认原来处于断热状态- 之后
safety_status查询forced_off=0 - 温度曲线恢复上升趋势
TEST-04:验证 OTA 期间 PTC 断热
目的:确认 OTA 开始时加热器被立即切断。
步骤:
- 下发 OTA 命令(任意 URL,哪怕失败也行,关键是观察初始行为)
- 同时订阅 MQTT status topic
预期串口日志(最优先出现):
I (xxx) petbox_ota: OTA: heater forced OFF before download
预期 MQTT(OTA started ACK 之前就应看到 heater_forced_off=true):
- 下一个
safety_status上报中heater_off_reason="ota_start"
验收条件:OTA ACK "stage":"started" 的时间戳 晚于 heater 被切断的时间戳。
TEST-05:验证 I2C 恢复(需要硬件操作)
目的:确认 SHT30 断线后能自动恢复,不需要重启设备。
步骤:
- 观察 MQTT 温湿度正常上报
- 短暂断开 SHT30 的 SDA 或 SCL 引脚(2-3 秒后恢复)
- 观察串口日志
预期日志(断线期间):
E (xxx) SHT: Failed to read temperature and humidity
E (xxx) SHT: Failed to read temperature and humidity
... (共 5 次)
W (xxx) SHT: I2C full restart: deleting driver and reinitialising
I (xxx) SHT: SHT30 soft reset sent
恢复后:
I (xxx) SHT: I2C device found at 0x44
I (xxx) SHT: SHT sensor detected: SHT30
I (xxx) SHT: Current: Temperature=37.xx°C ...
验收条件:
- 重新接好后 30s 内温湿度恢复正常上报
- 不需要重启设备
- MQTT 温度恢复上报(不再冻结)
TEST-06:验证断网不重启
目的:确认 WiFi 断开或 MQTT 断连不会触发 WDT 重启。
步骤:
- 关闭 WiFi 路由器或设备的 WiFi AP
- 等待 3-5 分钟
- 恢复 WiFi 连接
观察:
- 断网期间:串口日志只有 WiFi 重连重试,无 WDT 触发日志
- 断网期间:
sensor_sht_task仍在运行,串口显示温湿度读取正常 - 恢复后:设备重新连接 WiFi → MQTT → 自动继续上报
验收条件:
- 断网 5 分钟内设备没有重启(通过
boot_reset_reason确认,恢复连接后仍为power_on) - 断网期间温控继续工作(串口温度数据持续更新)
TEST-07:验证复位原因上报
目的:确认 boot_reset_reason 能正确区分重启原因。
步骤:
| 操作 | 预期 boot_reset_reason |
|---|---|
| 正常上电(断电重插) | power_on |
MQTT 发送 esp_restart 命令 | sw_reset |
| 手动按 EN 复位按钮 | ext_reset |
| 制造 panic(代码 assert) | panic |
查看方式:设备连接 MQTT 后,等待最多 10s,safety_status 消息中包含 boot_reset_reason 字段。
也可通过串口开机日志直接查看:
W (xxx) MAIN: Boot reset reason: sw_reset (3)
TEST-08:验证 NTP 异步化(断网下仍可控温)
目的:确认 NTP 同步失败时 MQTT 和温控不受影响。
步骤:
- 设备断网启动(或启动后立即断网,在 NTP 同步期间)
- 观察 MQTT 连接和温控行为
预期:
http_server_start()和petbox_mqtt_start()正常执行(不等待 NTP)- NTP 重试 10 次失败后放弃,串口日志:
E (xxx) NTP: Failed to synchronize time. - 温湿度上报、加热控制正常继续(完全不依赖 NTP)
- 时间显示为 "Time not set",但功能不受影响
验收条件:NTP 失败不导致 MQTT 断开、不导致温控停止、不导致重启。
量产(MP)编译配置
在 main/CMakeLists.txt 中控制测试命令的开关:
target_compile_definitions(${COMPONENT_LIB} PRIVATE
PETBOX_USB_HOST_ENABLE=1
SAFETY_TEST_ENABLED=0 # ← MP 量产时改为 0
)
SAFETY_TEST_ENABLED=0 时:
safety_test_stall、safety_test_heater_off命令被编译器完全移除safety_status、safety_recover命令保留(运维必要工具)- 安全冗余本身(心跳、断热、WDT)不受影响,永远开启
关键常量速查
| 常量 | 位置 | 默认值 | 说明 |
|---|---|---|---|
SAFETY_CTRL_STALL_MS | petboxX.c | 10000 ms | 心跳超时阈值 |
SAFETY_WDT_TIMEOUT_S | petboxX.c | 60 s | Task WDT 超时 |
HIGH_TEMP_ALERT_THRESHOLD | sensor_sht.c | 39.0 °C | 高温告警阈值 |
MAX_RETRY_COUNT | ntp_time_sync.c | 10 次 | NTP 同步最大重试次数 |
| I2C 全重初始化触发 | sensor_sht.c | 5 次连续失败 | 触发完整 I2C 驱动重装 |
已知局限与后续改进
| 项目 | 当前状态 | 建议改进 |
|---|---|---|
| 风扇卡死自恢复 | 未实现 | PWM > 0 但 RPM = 0 超过 N 秒 → 重初始化 LEDC |
| WDT 触发 panic | trigger_panic=false | 待充分测试后可改为 true,并在 boot_reset_reason=task_wdt 时发 MQTT 告警 |
| forced-off 持久化 | 重启后清除 | OTA 场景下可接受;其他场景可考虑写 NVS |
| stall 后主动保持排风 | 未显式设置 | 在 safety_monitor 检测到 stall 后调用 set_back_fan_speed(30) |
文档生成日期:2025-05-17 | 固件 commit:待定