Compare commits

...

7 Commits

Author SHA1 Message Date
yangzhijia
8facb8b719 chore: 忽略 ai/sessions/,从索引移除已跟踪的会话日志 2026-08-12 17:30:06 +08:00
yangzhijia
cc3ba5a2f8 feat:增加多设备烧录 2026-08-12 17:24:19 +08:00
yangzhijia
36f5a285f4 feat:增加蓝牙配网设备编号 2026-08-12 17:19:07 +08:00
yangzhijia
08a055b611 fix:修补蓝牙配网bug 2026-08-12 09:22:28 +08:00
yangzhijia
93112dcdfd feat:增加蓝牙配网功能 2026-08-12 09:20:54 +08:00
yangzhijia
2ce4ac0b9c 版本1.2 2026-08-06 13:58:45 +08:00
yangzhijia
69e927c5f0 feat: 按钮去抖+配网界面美化+水浸离线+LED独立任务+报警全拓展(人身优先)+有人才检测 2026-07-31 15:58:09 +08:00
20 changed files with 1622 additions and 908 deletions

View File

@@ -7,7 +7,24 @@
"WebSearch",
"WebFetch(domain:boardor.com)",
"Read(//d//**)",
"PowerShell(Get-Content \"D:\\\\R60AFD1_数据手册_V1.2.pdf\" -Raw -Encoding Byte | Out-Null; Write-Host \"File exists, size:\" \\(\\(Get-Item \"D:\\\\R60AFD1_数据手册_V1.2.pdf\"\\).Length\\);)"
"PowerShell(Get-Content \"D:\\\\R60AFD1_数据手册_V1.2.pdf\" -Raw -Encoding Byte | Out-Null; Write-Host \"File exists, size:\" \\(\\(Get-Item \"D:\\\\R60AFD1_数据手册_V1.2.pdf\"\\).Length\\);)",
"Bash(py -m pip install openpyxl -q)",
"Read(//d/PlatformIO1/Projects/**)",
"Bash(pio pkg *)",
"Bash(git stash *)",
"Bash(git checkout *)",
"Bash(sed -i 's/#define OLED_SDA_PIN 14/#define OLED_SDA_PIN 15/' include/config.h)",
"Bash(sed -i 's/#define OLED_SCL_PIN 15/#define OLED_SCL_PIN 16/' include/config.h)",
"Bash(sed -i 's/#define BTN_PROVISION_PIN 16/#define BTN_PROVISION_PIN 14/' include/config.h)",
"Bash(sed -i 's/#define BTN_PAGE_SWITCH_PIN 10/#define BTN_PAGE_SWITCH_PIN 5/' include/config.h)",
"Bash(sed -i 's/#define BTN_LONG_PRESS_MS 3000/#define BTN_LONG_PRESS_MS 1500/' include/config.h)",
"Bash(sed -i 's/#define WATER_SENSOR_PIN 6 /#define WATER_SENSOR_PIN 45/' include/config.h)",
"Bash(sed -i 's/#define BUZZER_PIN 7 /#define BUZZER_PIN 21/' include/config.h)",
"PowerShell(Get-ChildItem \"$env:USERPROFILE\\\\.claude\" -ErrorAction SilentlyContinue; if \\(-not $?\\) { Write-Host \"No .claude at USERPROFILE\" })",
"Read(//c/Users/杨志佳/.claude/**)",
"Read(//d/ClaudeData/.claude/**)",
"Read(//c/Users/杨志佳/.claude/projects/d--PlatformIO1-Projects-Healthv2-0/memory/**)",
"Read(//d/PlatformIO1/Projects/Healthv2.0/ai/sessions/memory/**)"
]
}
}

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
# AI 会话日志——不入库
ai/sessions/

View File

@@ -1,9 +1,12 @@
/**
* @file alarm_manager.h
* @brief 报警管理器 - 双模式 (本地判定 + 服务端指令)
* @brief 报警管理器 - 多类型分优先级报警 (本地判定 + 服务端指令)
*
* 本地模式: 离线也能用, 跌倒/水浸/心率异常/呼吸异常 本地判定触发
* 优先级: 跌倒 > 水浸 > 心率异常 > 呼吸异常 > 温湿度异常 > 传感器故障
*
* 本地模式: 离线也能用, 全部类型本地判定触发
* 服务端模式: 收到 cmd/{gateid}/alarm, alarm/restore 执行联动
* 防抖: 传感器故障需连续 N 次确认才触发, 避免误报
*/
#ifndef ALARM_MANAGER_H
@@ -12,16 +15,23 @@
#include <Arduino.h>
#include "config.h"
// 报警回调 — 通知外部 (如 MQTT 上报)
typedef void (*AlarmCallback)(uint8_t type, bool triggered, const char* desc);
class AlarmManager {
public:
// 告警类型 (本地 + 服务端共用)
enum AlarmType {
// 告警类型 (按优先级从高到低)
enum AlarmType : uint8_t {
ALARM_NONE = 0,
ALARM_FALL, // 跌倒 (本地 + 服务端)
ALARM_WATER_LEAK, // 水浸 (本地 + 服务端)
ALARM_HEART_ABNORMAL, // 心率异常 (本地)
ALARM_BREATH_ABNORMAL, // 呼吸异常 (本地)
ALARM_SERVER, // 服务端下发 (通用)
ALARM_FALL, // 跌倒 (本地 + 服务端) ★★★★★
ALARM_WATER_LEAK, // 水浸 (本地 + 服务端) ★★★★
ALARM_HEART_ABNORMAL, // 心率异常 (本地) ★★★
ALARM_BREATH_ABNORMAL, // 呼吸异常 (本地) ★★★
ALARM_TEMP_ABNORMAL, // 温度异常 (本地) ★★
ALARM_HUMI_ABNORMAL, // 湿度异常 (本地) ★★
ALARM_SENSOR_FAULT, // 传感器故障 (本地) ★
ALARM_SERVER, // 服务端下发 (通用)
ALARM_COUNT
};
AlarmManager();
@@ -34,11 +44,11 @@ public:
// 本地报警接口 (离线可用)
// ============================================================
/** 触发本地报警 */
void triggerLocal(AlarmType type);
/** 触发本地报警 (防抖由调用方处理) */
void triggerLocal(AlarmType type, const char* detail = nullptr);
/** 清除本地报警 */
void clearLocal(AlarmType type);
/** 清除所有本地报警 */
/** 清除所有本地报警 (含防抖计数器) */
void clearAllLocal();
// ============================================================
@@ -53,33 +63,58 @@ public:
void buzzerTest(uint32_t durationSec);
// ============================================================
// 状态查询 (上报用)
// 回调注册
// ============================================================
bool isBuzzerOn() const { return _buzzerOn; }
bool hasActiveAlarm() const { return _activeCount > 0; }
const char* getActiveAlarmType() const;
/** 报警状态变化回调 (触发/清除时调用, 用于MQTT上报) */
void onAlarmChange(AlarmCallback cb) { _alarmCb = cb; }
// ============================================================
// 状态查询
// ============================================================
bool isBuzzerOn() const { return _buzzerOn; }
bool hasActiveAlarm() const { return _activeCount > 0; }
uint8_t getActiveCount() const { return _activeCount; }
AlarmType getHighestAlarm() const;
const char* getAlarmName(AlarmType type) const;
const char* getActiveAlarmStr() const;
private:
// 报警优先级 (越小越紧急)
static uint8_t _priority(AlarmType t);
void _setBuzzer(bool on);
void _setLED(bool on);
void _updateBuzzer();
void _updateLED();
uint32_t _buzzerIntervalFor(AlarmType type);
// 蜂鸣器/LED 状态
bool _buzzerOn;
bool _ledOn;
uint32_t _buzzerOffTime; // 定时关闭时刻 (0=手动)
uint32_t _buzzerOffTime; // 定时关闭时刻 (0=手动/无限)
uint32_t _buzzerToggleTime; // 间歇切换时刻
bool _buzzerBeepState; // 间歇状态 (响/停)
uint8_t _beepCount; // 当前周期哔了几声
uint8_t _beepsPerCycle; // 当前报警类型每周期几声
uint32_t _beepOnMs; // 每声持续 ms
uint32_t _beepOffMs; // 每声间隔 ms
uint32_t _cyclePauseMs; // 周期间停顿 ms
// 活动报警列表 (最多 4 个同时)
AlarmType _activeAlarms[4];
// 活动报警列表 (最多 5 个同时)
static const uint8_t MAX_ACTIVE = 5;
AlarmType _activeAlarms[MAX_ACTIVE];
uint8_t _activeCount;
// 当前详情 (用于上报)
char _activeDetail[64];
// LED 闪烁
uint32_t _lastLEDToggle;
// 回调
AlarmCallback _alarmCb;
};
#endif // ALARM_MANAGER_H

View File

@@ -1,10 +1,13 @@
/**
* @file button_manager.h
* @brief 三按钮管理器 - 去抖 + 长按/短按识别
* @brief 三按钮管理器 - 双向去抖 + 长按/短按识别 + 超时自愈
*
* 按钮1 (GPIO14): 长按3秒 → 配网
* 按钮2 (GPIO15): 短按 → 解除报警
* 按钮3 (GPIO16): 短按 → 翻页
* 按钮1 (GPIO14): 长按1.5秒 → 配网
* 按钮2 (GPIO13): 短按 → 解除报警
* 按钮3 (GPIO46): 短按 → 翻页
*
* 去抖策略: 状态变化需持续稳定 BTN_DEBOUNCE_MS 才确认
* 自愈策略: 按钮按下超过 10 秒强制复位,防止卡死
*/
#ifndef BUTTON_MANAGER_H
@@ -13,6 +16,8 @@
#include <Arduino.h>
#include "config.h"
#define BTN_STUCK_TIMEOUT_MS 10000 // 按钮卡死超时 (10秒强制复位)
typedef void (*ButtonCallback)(void);
class ButtonManager {
@@ -31,17 +36,32 @@ public:
void onPageSwitch(ButtonCallback cb) { _cbPage = cb; }
private:
/**
* 按钮状态机 (基于稳定读数而非边沿)
*
* 状态转换:
* IDLE ──[raw稳定LOW×BTN_DEBOUNCE_MS]──→ PRESSED (记录pressTime)
* PRESSED ──[hold≥BTN_LONG_PRESS_MS]──→ 触发长按回调 (仅限hasLong)
* PRESSED ──[raw稳定HIGH×BTN_DEBOUNCE_MS]──→ 检查hold时长触发短按 → IDLE
* PRESSED ──[hold≥BTN_STUCK_TIMEOUT_MS]──→ 强制复位 → IDLE (防卡死)
*/
struct ButtonState {
uint8_t pin;
bool pressed; // 当前是否在按下状态
uint32_t pressTime; // 按下时刻 (ms)
bool longTriggered; // 防止长按时重复触发短按
bool stableState; // 当前确认的稳定状态 (true=按下)
bool rawState; // 上一次 raw 读值
uint32_t changeTime; // raw 状态变化时刻
uint32_t pressTime; // 稳定按下时刻 (ms)
bool longTriggered; // 长按已触发
bool shortPending; // 等待释放以判定短按
};
/** 处理单个按钮的状态机 */
/** 处理单个按钮 (基于稳定状态而非瞬时边沿) */
void _processButton(ButtonState& btn, bool hasShort, bool hasLong);
bool _readPin(uint8_t pin);
/** 强制复位单个按钮 */
void _resetButton(ButtonState& btn);
ButtonState _btnProvision;
ButtonState _btnDismiss;
ButtonState _btnPage;

View File

@@ -16,10 +16,15 @@
// 1. 硬件引脚定义 (ESP32-S3)
// ============================================================
// --- R60ABD1 毫米波雷达 ---
// --- 雷达型号选择 ---
#define RADAR_TYPE_ABD1 0 // R60ABD1: 心率/呼吸精准, 跌倒一般
#define RADAR_TYPE_AFD1 1 // R60AFD1: 跌倒/姿态精准, 无心率呼吸
#define RADAR_TYPE RADAR_TYPE_ABD1 // AFD1 (跌倒/姿态精准, 卫生间用)
// --- 毫米波雷达 ---
#define RADAR_UART_PORT Serial2
#define RADAR_RX_PIN 17
#define RADAR_TX_PIN 1 // UART 发送 (从18挪到1)
#define RADAR_TX_PIN 1
#define RADAR_BAUD_RATE 115200
// --- 雷达备用 GPIO ---
@@ -30,10 +35,10 @@
#define DHT11_PIN 4 // 温湿度传感器
// --- 水浸传感器 ---
#define WATER_SENSOR_PIN 6 // 数字输入, 低电平=有水
#define WATER_SENSOR_PIN 45 // 数字输入, 低电平=有水
// --- 蜂鸣器 (PWM) ---
#define BUZZER_PIN 7
#define BUZZER_PIN 21
#define BUZZER_PWM_CHANNEL 0
#define BUZZER_PWM_FREQ 2000
#define BUZZER_PWM_RESOLUTION 8
@@ -47,15 +52,15 @@
#define LED_ALARM_PIN 9 // 红色 - 报警指示
// --- OLED 显示屏 (SSD1306 128x64 I2C) ---
#define OLED_SDA_PIN 14 // I2C 数据线
#define OLED_SCL_PIN 15 // I2C 时钟线
#define OLED_SDA_PIN 15 // I2C 数据线
#define OLED_SCL_PIN 16 // I2C 时钟线
// --- 交互按钮 ---
#define BTN_PROVISION_PIN 16 // 擦除WiFi配网按钮 (长按3秒)
#define BTN_PROVISION_PIN 14 // 擦除WiFi配网按钮 (长按3秒)
#define BTN_ALARM_DISMISS_PIN 13 // 解除报警按钮
#define BTN_PAGE_SWITCH_PIN 10 // 翻页按钮
#define BTN_PAGE_SWITCH_PIN 46 // 翻页按钮
#define BTN_DEBOUNCE_MS 50 // 去抖时间
#define BTN_LONG_PRESS_MS 3000 // 长按判定
#define BTN_LONG_PRESS_MS 1500 // 长按判定
// ============================================================
// 2. 设备标识 (协议规范 §2)
@@ -71,9 +76,9 @@
// 3. WiFi 默认配置 (配网使用)
// ============================================================
#define WIFI_AP_SSID "HealthMonitor-Setup"
#define WIFI_AP_PASSWORD "12345678"
#define WIFI_CONFIG_TIMEOUT 300
#define WIFI_AP_SSID_PREFIX "HealthMonitor-" // 末尾拼 MAC 末4位, 保证每台设备唯一
#define WIFI_AP_PASSWORD "1234567890"
#define WIFI_CONFIG_TIMEOUT 120 // 配网超时(秒), 超时回连上一个 WiFi
#define WIFI_CONNECT_TIMEOUT 15
#define WIFI_RETRY_INTERVAL 10000
@@ -115,8 +120,9 @@
// 6. 上报配置 (协议规范 §4.1, §7.2)
// ============================================================
#define REPORT_INTERVAL_MS 1000 // 遥测上报周期 1秒
#define REPORT_INTERVAL_MS 1000 // 遥测上报周期 1秒 (有人时)
#define REPORT_QOS 0 // 遥测 QoS 0
#define PRESENCE_HEARTBEAT_MS 60000 // 无人心跳间隔 60秒 (4G省流量)
#define STATUS_QOS 1 // 状态/响应 QoS 1
// ============================================================
@@ -130,6 +136,7 @@
#define TEMP_LOW 5.0f
#define TEMP_HIGH 45.0f
#define HUMI_LOW 15.0f
#define PRESENCE_HYSTERESIS 8 // 有人无人防抖 (PCB信号抖动时过滤)
#define HUMI_HIGH 90.0f
#define WATER_DEBOUNCE_MS 50
@@ -167,10 +174,11 @@
struct TelemetryData {
// --- 设备标识 ---
char saleid[12]; // 站点编号 "10100001"
char gateid[16]; // 网关编号 "10100001001"
char meterId[8]; // 节点ID "ENV001"
char saleid[12]; // 站点编号 (已弃用)
char gateid[16]; // 网关编号
char meterId[8]; // 节点ID (已弃用, 新用 gateid+后缀)
char meterName[16]; // 设备型号 "RadarDHT11"
char deviceType[16]; // 设备类型枚举 bedroom/bathroom/...
// --- 环境数据 (DHT11) ---
float temp; // 温度 ℃
@@ -187,6 +195,8 @@ struct TelemetryData {
uint8_t heartRate; // 心率 bpm (无效=0)
uint8_t breathRate; // 呼吸 次/分 (无效=0)
uint8_t body; // 人体能量/体征值
uint8_t sleepState; // 睡眠状态 0=清醒 1=浅睡 2=深睡
uint8_t sleepScore; // 睡眠评分 0~100
bool radarValid; // 雷达数据有效性
bool vitalValid; // 心率呼吸有效性

View File

@@ -26,11 +26,14 @@ public:
/**
* @brief 更新显示内容 (每 5 秒调用一次即可)
* @param data 遥测数据
* @param wifiOk WiFi 是否已连接
* @param buzzerOn 蜂鸣器是否在响
* @param data 遥测数据
* @param wifiOk WiFi 是否已连接
* @param buzzerOn 蜂鸣器是否在响
* @param hasAlarm 是否有活跃报警
* @param alarmName 报警名称 (如 "跌倒"/"心率异常")
*/
void update(const TelemetryData& data, bool wifiOk, bool buzzerOn);
void update(const TelemetryData& data, bool wifiOk, bool buzzerOn,
bool hasAlarm = false, const char* alarmName = "");
/** 切换到下一页 (0→1→2→3→0 循环) */
void nextPage();
@@ -39,10 +42,11 @@ public:
uint8_t getPage() const { return _pageIndex; }
private:
void _drawPage0(const TelemetryData& data, bool wifiOk, bool buzzerOn);
void _drawPage1(const TelemetryData& data);
void _drawPage2(const TelemetryData& data);
void _drawPage3(const TelemetryData& data, bool buzzerOn, bool wifiOk);
void _drawPage0(const TelemetryData& data, bool wifiOk, bool buzzerOn, bool hasAlarm);
void _drawPage1(const TelemetryData& data, bool hasAlarm);
void _drawPage2(const TelemetryData& data, bool hasAlarm);
void _drawPage3(const TelemetryData& data, bool buzzerOn, bool wifiOk, bool hasAlarm);
void _drawAlarmBanner(const char* name);
/** 格式化秒数为 HH:MM:SS */
String _fmtUptime(uint32_t secs) const;

View File

@@ -80,6 +80,12 @@ public:
const char* errorCode, const char* errorMsg,
const char* wifiSignal = "");
/** 本地报警上报 status/{gateid}/alarm (§4.7 扩展) */
void publishLocalAlarm(uint8_t alarmType, bool triggered, const char* desc);
/** 无人极简报文 (仅 Presence+RadarValid, 省4G流量) */
void publishPresenceOnly(bool presence, bool radarValid, uint32_t sequence);
// ============================================================
// 回调注册 (协议规范 §5)
// ============================================================

View File

@@ -21,6 +21,8 @@ struct RadarRawData {
uint8_t breathRate; // 呼吸
uint8_t body; // 体动/能量值
uint16_t distance; // 距离 cm
uint8_t sleepState; // 睡眠状态: 0=清醒 1=浅睡 2=深睡
uint8_t sleepScore; // 睡眠评分: 0~100
bool radarValid; // 雷达有效性
bool vitalValid; // 生命体征有效性
@@ -68,6 +70,7 @@ private:
void _extractPresence(const RadarFrame& frame);
void _extractBreathing(const RadarFrame& frame);
void _extractHeartRate(const RadarFrame& frame);
void _extractFall(const RadarFrame& frame); // AFD1 跌倒
void _extractSleep(const RadarFrame& frame);
void _extractWorkStatus(const RadarFrame& frame);
void _extractDetectRange(const RadarFrame& frame);
@@ -85,6 +88,13 @@ private:
RadarCallback _callback;
uint32_t _frameCount;
uint32_t _errorCount;
uint32_t _lastHeartBeat; // 上次心率数据时间
uint32_t _lastBreathBeat; // 上次呼吸数据时间
// 有人无人防抖 (PCB 信号不稳定时过滤跳变)
bool _stablePresence; // 防抖后的稳定有人/无人
uint8_t _presenceCnt; // 连续一致计数
bool _lastRawPresence; // 上一次原始值
};
#endif // RADAR_MANAGER_H

View File

@@ -33,9 +33,10 @@ enum RadarControl : uint8_t {
CTRL_WORK_STATUS = 0x05, // 工作状态
CTRL_DETECT_RANGE = 0x07, // 探测范围
CTRL_PRESENCE = 0x80, // 人体存在
CTRL_BREATHING = 0x81, // 呼吸检测
CTRL_SLEEP = 0x84, // 睡眠监测
CTRL_HEART_RATE = 0x85, // 心率监测
CTRL_BREATHING = 0x81, // 呼吸检测 (仅 ABD1)
CTRL_FALL = 0x83, // 跌倒检测 (仅 AFD1)
CTRL_SLEEP = 0x84, // 睡眠监测 (仅 ABD1)
CTRL_HEART_RATE = 0x85, // 心率监测 (仅 ABD1)
};
// ============================================================
@@ -73,6 +74,7 @@ enum SleepStatus : uint8_t {
SLEEP_AWAKE = 0, // 清醒
SLEEP_LIGHT = 1, // 浅睡
SLEEP_DEEP = 2, // 深睡
SLEEP_ABSENT = 3, // 离床/无人/无效
};
// ============================================================

View File

@@ -1,10 +1,10 @@
/**
* @file wifi_manager.h
* @brief WiFi 配网管理器
* @brief WiFi 配网管理器 (AP + WebServer)
*
* 功能:
* - 自动连接已保存的 WiFi
* - 配网失败/首次启动 → AP 模式 + 强制门户页面
* - 配网失败/首次启动 → AP 模式 + Captive Portal
* - 自定义参数: MQTT 服务器/端口/用户/密码、设备名称、环境备注
* - 长按按钮重置配置
* - LED 状态指示
@@ -15,8 +15,14 @@
#define WIFI_MANAGER_H
#include <Arduino.h>
#include <WiFiManager.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <LittleFS.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <ArduinoJson.h>
#include "config.h"
@@ -27,7 +33,10 @@ struct DeviceConfig {
char mqtt_user[20];
char mqtt_pass[20];
char device_id[20];
char env_note[20]; // 环境备注 (如: "主卧", "客厅", "老人房")
char env_note[20]; // 环境备注 (房间名)
char env_type[16]; // 环境类型 bedroom/bathroom/...
char wifi_ssid[33]; // WiFi SSID
char wifi_pass[65]; // WiFi 密码
};
class WiFiMgmt {
@@ -38,95 +47,75 @@ public:
// 单例指针 (用于静态回调)
static WiFiMgmt* _instance;
/**
* @brief 初始化 WiFi 管理器
* @return true=成功
*/
bool begin();
/**
* @brief 主循环, 处理配网流程和按钮检测
* @return true=WiFi 已连接
*/
bool loop();
/**
* @brief WiFi 是否已连接
*/
bool isConnected() const;
/**
* @brief 获取设备配置
*/
bool justProvisioned(); // 刚配网成功 (读取后自动清除)
DeviceConfig getConfig() const;
/**
* @brief 获取设备 ID (用于 MQTT client_id)
*/
const char* getDeviceId() const;
/**
* @brief 获取环境备注
*/
const char* getEnvNote() const;
/**
* @brief 强制进入配网模式
*/
void startConfigPortal();
/**
* @brief 清除所有配置并重启
*/
void handleBLEProvision(const char* json); // BLE 配网回调
void enqueueBLEJson(const char* json); // BLE 线程安全入队 (仅暂存)
void resetSettings();
private:
/**
* @brief 尝试连接 WiFi
*/
bool _connectWiFi();
/**
* @brief 设置 LED 状态
* @param mode 0=慢闪(未连接) 1=快闪(配网) 2=常亮(已连接)
*/
void _setLEDMode(uint8_t mode);
/**
* @brief 检测按钮长按
*/
bool _checkButtonLongPress();
/**
* @brief 加载/保存配置到 LittleFS
*/
bool _connectWiFi(); // 仅尝试已保存凭据
bool _tryConnect(const char* ssid, const char* pass); // 尝试连接指定 WiFi
bool _loadConfig();
bool _saveConfig();
void _setLEDMode(uint8_t mode);
static void _ledTaskFunc(void* param);
bool _checkButtonLongPress();
/**
* @brief 保存 WiFi 参数回调 (WiFiManager 的回调)
*/
static void _saveParamsCallback();
static void _apCallback(WiFiManager* wm);
// Web 服务器
void _startPortal();
void _stopPortal();
void _handleRoot();
void _handleSave();
void _handleReset();
void _updateWifiScan(); // WiFi 扫描缓存
String _htmlPage(); // 返回配网 HTML 页面
WiFiManager _wm;
DeviceConfig _config;
bool _connected;
uint32_t _lastCheckTime;
uint32_t _buttonPressTime;
bool _buttonPressed;
// BLE 蓝牙配网
void _startBLE();
void _stopBLE();
// LED 闪烁状态
uint32_t _ledLastToggle;
bool _ledState;
WebServer* _server;
DNSServer* _dns;
DeviceConfig _config;
bool _connected;
bool _portalActive;
bool _portalStopFlag; // 延迟关闭标记
uint32_t _portalStartTime;
String _pendingSSID;
String _pendingPass;
bool _provisionSuccess; // 刚配网成功标记
uint32_t _lastCheckTime;
uint32_t _buttonPressTime;
bool _buttonPressed;
// WiFiManager 自定义参数
WiFiManagerParameter* _param_mqtt_server;
WiFiManagerParameter* _param_mqtt_port;
WiFiManagerParameter* _param_mqtt_user;
WiFiManagerParameter* _param_mqtt_pass;
WiFiManagerParameter* _param_device_id;
WiFiManagerParameter* _param_env_note;
// WiFi 扫描缓存 (避免每次打开页面都扫描3-8秒)
String _wifiScanCache;
uint32_t _lastScanTime;
// LED 独立任务
TaskHandle_t _ledTaskHandle;
volatile uint8_t _ledMode; // 0=灭 1=慢闪 2=快闪 3=常亮
// BLE
BLEServer* _bleServer;
BLECharacteristic* _bleInfoChar; // READ: 设备信息
BLECharacteristic* _bleChar; // WRITE+NOTIFY: 配网
BLEAdvertising* _bleAdv;
// BLE 回调保护: 不在 BTC_TASK 栈里做 Flash 写操作
String _blePendingJson; // 待处理的 BLE 配网 JSON
bool _blePendingFlag; // 有未处理的 BLE 数据
void _processBLEPending(); // 在 loop() 上下文处理
// AP SSID (前缀+MAC后4位, BLE 命名复用)
String _apSsid;
};
#endif // WIFI_MANAGER_H

View File

@@ -1,262 +0,0 @@
/**
* wm_strings_zh.h
* 中文语言包 for WiFiManager
* 基于 wm_strings_en.h 翻译
*
* WiFiManager, a library for the ESP8266/Arduino platform
* for configuration of WiFi credentials using a Captive Portal
*
* @author Creator tzapu
* @author tablatronix
* @version 0.0.0
* @license MIT
*/
#ifndef _WM_STRINGS_ZH_H_
#define _WM_STRINGS_ZH_H_
// strings files must include a consts file!
#include "wm_consts_en.h" // include constants, tokens, routes
const char WM_LANGUAGE[] PROGMEM = "zh-CN"; // i18n lang code
const char HTTP_HEAD_START[] PROGMEM = "<!DOCTYPE html>"
"<html lang='zh-CN'><head>"
"<meta name='format-detection' content='telephone=no'>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
"<title>{v}</title>";
const char HTTP_SCRIPT[] PROGMEM = "<script>function c(l){"
"document.getElementById('s').value=l.getAttribute('data-ssid')||l.innerText||l.textContent;"
"p = l.nextElementSibling.classList.contains('l');"
"document.getElementById('p').disabled = !p;"
"if(p)document.getElementById('p').focus();};"
"function f() {var x = document.getElementById('p');x.type==='password'?x.type='text':x.type='password';}"
"</script>";
const char HTTP_HEAD_END[] PROGMEM = "</head><body class='{c}'><div class='wrap'>"; // {c} = _bodyclass
const char HTTP_ROOT_MAIN[] PROGMEM = "<h1>{t}</h1><h3>{v}</h3>";
const char * const HTTP_PORTAL_MENU[] PROGMEM = {
"<form action='/wifi' method='get'><button>配置 WiFi</button></form><br/>\n", // MENU_WIFI
"<form action='/0wifi' method='get'><button>配置 WiFi (不扫描)</button></form><br/>\n", // MENU_WIFINOSCAN
"<form action='/info' method='get'><button>系统信息</button></form><br/>\n", // MENU_INFO
"<form action='/param' method='get'><button>参数设置</button></form><br/>\n", // MENU_PARAM
"<form action='/close' method='get'><button>关闭页面</button></form><br/>\n", // MENU_CLOSE
"<form action='/restart' method='get'><button>重启设备</button></form><br/>\n", // MENU_RESTART
"<form action='/exit' method='get'><button>退出配网</button></form><br/>\n", // MENU_EXIT
"<form action='/erase' method='get'><button class='D'>清除配置</button></form><br/>\n", // MENU_ERASE
"<form action='/update' method='get'><button>固件升级</button></form><br/>\n", // MENU_UPDATE
"<hr><br/>" // MENU_SEP
};
const char HTTP_PORTAL_OPTIONS[] PROGMEM = "";
const char HTTP_ITEM_QI[] PROGMEM = "<div role='img' aria-label='{r}%' title='{r}%' class='q q-{q} {i} {h}'></div>";
const char HTTP_ITEM_QP[] PROGMEM = "<div class='q {h}'>{r}%</div>";
const char HTTP_ITEM[] PROGMEM = "<div><a href='#p' onclick='c(this)' data-ssid='{V}'>{v}</a>{qi}{qp}</div>";
const char HTTP_FORM_START[] PROGMEM = "<form method='POST' action='{v}'>";
const char HTTP_FORM_WIFI[] PROGMEM = "<label for='s'>WiFi 名称 (SSID)</label><input id='s' name='s' maxlength='32' autocorrect='off' autocapitalize='none' placeholder='{v}'><br/><label for='p'>WiFi 密码</label><input id='p' name='p' maxlength='64' type='password' placeholder='{p}'><input type='checkbox' id='showpass' onclick='f()'> <label for='showpass'>显示密码</label><br/>";
const char HTTP_FORM_WIFI_END[] PROGMEM = "";
const char HTTP_FORM_STATIC_HEAD[] PROGMEM = "<hr><br/>";
const char HTTP_FORM_END[] PROGMEM = "<br/><br/><button type='submit'>保存配置</button></form>";
const char HTTP_FORM_LABEL[] PROGMEM = "<label for='{i}'>{t}</label>";
const char HTTP_FORM_PARAM_HEAD[] PROGMEM = "<hr><br/>";
const char HTTP_FORM_PARAM[] PROGMEM = "<br/><input id='{i}' name='{n}' maxlength='{l}' value='{v}' {c}>\n";
const char HTTP_SCAN_LINK[] PROGMEM = "<br/><form action='/wifi?refresh=1' method='POST'><button name='refresh' value='1'>重新扫描</button></form>";
const char HTTP_SAVED[] PROGMEM = "<div class='msg'>正在保存配置<br/>尝试连接 WiFi...<br/>如果连接失败,请重新连接 AP 再试一次</div>";
const char HTTP_PARAMSAVED[] PROGMEM = "<div class='msg S'>配置已保存<br/></div>";
const char HTTP_END[] PROGMEM = "</div></body></html>";
const char HTTP_ERASEBTN[] PROGMEM = "<br/><form action='/erase' method='get'><button class='D'>清除 WiFi 配置</button></form>";
const char HTTP_UPDATEBTN[] PROGMEM = "<br/><form action='/update' method='get'><button>固件升级</button></form>";
const char HTTP_BACKBTN[] PROGMEM = "<hr><br/><form action='/' method='get'><button>返回首页</button></form>";
const char HTTP_STATUS_ON[] PROGMEM = "<div class='msg S'><strong>已连接</strong> {v}<br/><em><small>IP 地址: {i}</small></em></div>";
const char HTTP_STATUS_OFF[] PROGMEM = "<div class='msg {c}'><strong>未连接</strong> {v}{r}</div>";
const char HTTP_STATUS_OFFPW[] PROGMEM = "<br/>WiFi 密码错误";
const char HTTP_STATUS_OFFNOAP[] PROGMEM = "<br/>未找到该 WiFi";
const char HTTP_STATUS_OFFFAIL[] PROGMEM = "<br/>无法连接";
const char HTTP_STATUS_NONE[] PROGMEM = "<div class='msg'>未设置 WiFi</div>";
const char HTTP_BR[] PROGMEM = "<br/>";
const char HTTP_STYLE[] PROGMEM = "<style>"
".c,body{text-align:center;font-family:'PingFang SC','Microsoft YaHei','Helvetica Neue',sans-serif}div,input,select{padding:5px;font-size:1em;margin:5px 0;box-sizing:border-box}"
"input,button,select,.msg{border-radius:.3rem;width: 100%}input[type=radio],input[type=checkbox]{width:auto}"
"button,input[type='button'],input[type='submit']{cursor:pointer;border:0;background-color:#1fa3ec;color:#fff;line-height:2.4rem;font-size:1.2rem;width:100%}"
"input[type='file']{border:1px solid #1fa3ec}"
".wrap {text-align:left;display:inline-block;min-width:260px;max-width:500px}"
"a{color:#000;font-weight:700;text-decoration:none}a:hover{color:#1fa3ec;text-decoration:underline}"
".q{height:16px;margin:0;padding:0 5px;text-align:right;min-width:38px;float:right}.q.q-0:after{background-position-x:0}.q.q-1:after{background-position-x:-16px}.q.q-2:after{background-position-x:-32px}.q.q-3:after{background-position-x:-48px}.q.q-4:after{background-position-x:-64px}.q.l:before{background-position-x:-80px;padding-right:5px}.ql .q{float:left}.q:after,.q:before{content:'';width:16px;height:16px;display:inline-block;background-repeat:no-repeat;background-position: 16px 0;"
"background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAQCAMAAADeZIrLAAAAJFBMVEX///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADHJj5lAAAAC3RSTlMAIjN3iJmqu8zd7vF8pzcAAABsSURBVHja7Y1BCsAwCASNSVo3/v+/BUEiXnIoXkoX5jAQMxTHzK9cVSnvDxwD8bFx8PhZ9q8FmghXBhqA1faxk92PsxvRc2CCCFdhQCbRkLoAQ3q/wWUBqG35ZxtVzW4Ed6LngPyBU2CobdIDQ5oPWI5nCUwAAAAASUVORK5CYII=');}"
"@media (-webkit-min-device-pixel-ratio: 2),(min-resolution: 192dpi){.q:before,.q:after {"
"background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALwAAAAgCAMAAACfM+KhAAAALVBMVEX///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAOrOgAAAADnRSTlMAESIzRGZ3iJmqu8zd7gKjCLQAAACmSURBVHgB7dDBCoMwEEXRmKlVY3L//3NLhyzqIqSUggy8uxnhCR5Mo8xLt+14aZ7wwgsvvPA/ofv9+44334UXXngvb6XsFhO/VoC2RsSv9J7x8BnYLW+AjT56ud/uePMdb7IP8Bsc/e7h8Cfk912ghsNXWPpDC4hvN+D1560A1QPORyh84VKLjjdvfPFm++i9EWq0348XXnjhhT+4dIbCW+WjZim9AKk4UZMnnCEuAAAAAElFTkSuQmCC');"
"background-size: 95px 16px;}}"
".msg{padding:20px;margin:20px 0;border:1px solid #eee;border-left-width:5px;border-left-color:#777}.msg h4{margin-top:0;margin-bottom:5px}.msg.P{border-left-color:#1fa3ec}.msg.P h4{color:#1fa3ec}.msg.D{border-left-color:#dc3630}.msg.D h4{color:#dc3630}.msg.S{border-left-color: #5cb85c}.msg.S h4{color: #5cb85c}"
"dt{font-weight:bold}dd{margin:0;padding:0 0 0.5em 0;min-height:12px}"
"td{vertical-align: top;}"
".h{display:none}"
"button{transition: 0s opacity;transition-delay: 3s;transition-duration: 0s;cursor: pointer}"
"button.D{background-color:#dc3630}"
"button:active{opacity:50% !important;cursor:wait;transition-delay: 0s}"
"body.invert,body.invert a,body.invert h1 {background-color:#060606;color:#fff;}"
"body.invert .msg{color:#fff;background-color:#282828;border-top:1px solid #555;border-right:1px solid #555;border-bottom:1px solid #555;}"
"body.invert .q[role=img]{-webkit-filter:invert(1);filter:invert(1);}"
":disabled {opacity: 0.5;}"
"</style>";
#ifndef WM_NOHELP
const char HTTP_HELP[] PROGMEM =
"<br/><h3>可用页面</h3><hr>"
"<table class='table'>"
"<thead><tr><th>页面</th><th>功能</th></tr></thead><tbody>"
"<tr><td><a href='/'>/</a></td>"
"<td>主菜单页面</td></tr>"
"<tr><td><a href='/wifi'>/wifi</a></td>"
"<td>显示 WiFi 扫描结果并配置连接 (/0wifi 不扫描)</td></tr>"
"<tr><td><a href='/wifisave'>/wifisave</a></td>"
"<td>保存 WiFi 配置并连接设备</td></tr>"
"<tr><td><a href='/param'>/param</a></td>"
"<td>参数设置页面</td></tr>"
"<tr><td><a href='/info'>/info</a></td>"
"<td>系统信息页面</td></tr>"
"<tr><td><a href='/u'>/u</a></td>"
"<td>固件 OTA 升级</td></tr>"
"<tr><td><a href='/close'>/close</a></td>"
"<td>关闭弹出窗口,配网服务保持运行</td></tr>"
"<tr><td>/exit</td>"
"<td>退出配网模式,配网服务将关闭</td></tr>"
"<tr><td>/restart</td>"
"<td>重启设备</td></tr>"
"<tr><td>/erase</td>"
"<td>清除 WiFi 配置并重启。清除后设备不会自动连接,需重新配网</td></tr>"
"</table>"
"<p/>GitHub <a href='https://github.com/tzapu/WiFiManager'>https://github.com/tzapu/WiFiManager</a>.";
#else
const char HTTP_HELP[] PROGMEM = "";
#endif
const char HTTP_UPDATE[] PROGMEM = "上传新固件<br/><form method='POST' action='u' enctype='multipart/form-data' onchange=\"(function(el){document.getElementById('uploadbin').style.display = el.value=='' ? 'none' : 'initial';})(this)\"><input type='file' name='update' accept='.bin,application/octet-stream'><button id='uploadbin' type='submit' class='h D'>开始升级</button></form><small><a href='http://192.168.4.1/update' target='_blank'>* 如果在配网页面内无法使用,请在浏览器中打开 http://192.168.4.1</a><small>";
const char HTTP_UPDATE_FAIL[] PROGMEM = "<div class='msg D'><strong>升级失败!</strong><Br/>请重启设备后重试</div>";
const char HTTP_UPDATE_SUCCESS[] PROGMEM = "<div class='msg S'><strong>升级成功!</strong><Br/>设备正在重启...</div>";
#ifdef WM_JSTEST
const char HTTP_JS[] PROGMEM =
"<script>function postAjax(url, data, success) {"
" var params = typeof data == 'string' ? data : Object.keys(data).map("
" function(k){ return encodeURIComponent(k) + '=' + encodeURIComponent(data[k]) }"
" ).join('&');"
" var xhr = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject(\"Microsoft.XMLHTTP\");"
" xhr.open('POST', url);"
" xhr.onreadystatechange = function() {"
" if (xhr.readyState>3 && xhr.status==200) { success(xhr.responseText); }"
" };"
" xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');"
" xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');"
" xhr.send(params);"
" return xhr;}"
"postAjax('/status', 'p1=1&p2=Hello+World', function(data){ console.log(data); });"
"postAjax('/status', { p1: 1, p2: 'Hello World' }, function(data){ console.log(data); });"
"</script>";
#endif
// Info html
#ifdef ESP32
const char HTTP_INFO_esphead[] PROGMEM = "<h3>ESP32 信息</h3><hr><dl>";
const char HTTP_INFO_chiprev[] PROGMEM = "<dt>芯片版本</dt><dd>{1}</dd>";
const char HTTP_INFO_lastreset[] PROGMEM = "<dt>上次复位原因</dt><dd>CPU0: {1}<br/>CPU1: {2}</dd>";
const char HTTP_INFO_aphost[] PROGMEM = "<dt>AP 主机名</dt><dd>{1}</dd>";
const char HTTP_INFO_psrsize[] PROGMEM = "<dt>PSRAM 大小</dt><dd>{1} 字节</dd>";
const char HTTP_INFO_temp[] PROGMEM = "<dt>芯片温度</dt><dd>{1}°C / {2}°F</dd>";
const char HTTP_INFO_hall[] PROGMEM = "<dt>霍尔传感器</dt><dd>{1}</dd>";
#else
const char HTTP_INFO_esphead[] PROGMEM = "<h3>ESP8266 信息</h3><hr><dl>";
const char HTTP_INFO_fchipid[] PROGMEM = "<dt>Flash 芯片 ID</dt><dd>{1}</dd>";
const char HTTP_INFO_corever[] PROGMEM = "<dt>Core 版本</dt><dd>{1}</dd>";
const char HTTP_INFO_bootver[] PROGMEM = "<dt>Boot 版本</dt><dd>{1}</dd>";
const char HTTP_INFO_lastreset[] PROGMEM = "<dt>上次复位原因</dt><dd>{1}</dd>";
const char HTTP_INFO_flashsize[] PROGMEM = "<dt>实际 Flash 大小</dt><dd>{1} 字节</dd>";
#endif
const char HTTP_INFO_memsmeter[] PROGMEM = "<br/><progress value='{1}' max='{2}'></progress></dd>";
const char HTTP_INFO_memsketch[] PROGMEM = "<dt>内存 - 程序大小</dt><dd>已用 / 总计<br/>{1} / {2} 字节";
const char HTTP_INFO_freeheap[] PROGMEM = "<dt>内存 - 可用堆</dt><dd>{1} 字节</dd>";
const char HTTP_INFO_wifihead[] PROGMEM = "<br/><h3>WiFi 信息</h3><hr>";
const char HTTP_INFO_uptime[] PROGMEM = "<dt>运行时间</dt><dd>{1} 分 {2} 秒</dd>";
const char HTTP_INFO_chipid[] PROGMEM = "<dt>芯片 ID</dt><dd>{1}</dd>";
const char HTTP_INFO_idesize[] PROGMEM = "<dt>Flash 大小</dt><dd>{1} 字节</dd>";
const char HTTP_INFO_sdkver[] PROGMEM = "<dt>SDK 版本</dt><dd>{1}</dd>";
const char HTTP_INFO_cpufreq[] PROGMEM = "<dt>CPU 频率</dt><dd>{1}MHz</dd>";
const char HTTP_INFO_apip[] PROGMEM = "<dt>AP IP 地址</dt><dd>{1}</dd>";
const char HTTP_INFO_apmac[] PROGMEM = "<dt>AP MAC 地址</dt><dd>{1}</dd>";
const char HTTP_INFO_apssid[] PROGMEM = "<dt>AP SSID</dt><dd>{1}</dd>";
const char HTTP_INFO_apbssid[] PROGMEM = "<dt>BSSID</dt><dd>{1}</dd>";
const char HTTP_INFO_stassid[] PROGMEM = "<dt>Station SSID</dt><dd>{1}</dd>";
const char HTTP_INFO_staip[] PROGMEM = "<dt>Station IP</dt><dd>{1}</dd>";
const char HTTP_INFO_stagw[] PROGMEM = "<dt>网关地址</dt><dd>{1}</dd>";
const char HTTP_INFO_stasub[] PROGMEM = "<dt>子网掩码</dt><dd>{1}</dd>";
const char HTTP_INFO_dnss[] PROGMEM = "<dt>DNS 服务器</dt><dd>{1}</dd>";
const char HTTP_INFO_host[] PROGMEM = "<dt>主机名</dt><dd>{1}</dd>";
const char HTTP_INFO_stamac[] PROGMEM = "<dt>Station MAC</dt><dd>{1}</dd>";
const char HTTP_INFO_conx[] PROGMEM = "<dt>连接状态</dt><dd>{1}</dd>";
const char HTTP_INFO_autoconx[] PROGMEM = "<dt>自动连接</dt><dd>{1}</dd>";
const char HTTP_INFO_aboutver[] PROGMEM = "<dt>WiFiManager 版本</dt><dd>{1}</dd>";
const char HTTP_INFO_aboutarduino[] PROGMEM = "<dt>Arduino 版本</dt><dd>{1}</dd>";
const char HTTP_INFO_aboutsdk[] PROGMEM = "<dt>ESP-SDK/IDF 版本</dt><dd>{1}</dd>";
const char HTTP_INFO_aboutdate[] PROGMEM = "<dt>构建日期</dt><dd>{1}</dd>";
// ============================================================
// 文本常量
// ============================================================
const char S_brand[] PROGMEM = "健康监测配网";
const char S_debugPrefix[] PROGMEM = "*wm:";
const char S_y[] PROGMEM = "";
const char S_n[] PROGMEM = "";
const char S_enable[] PROGMEM = "已启用";
const char S_disable[] PROGMEM = "已禁用";
const char S_GET[] PROGMEM = "GET";
const char S_POST[] PROGMEM = "POST";
const char S_NA[] PROGMEM = "未知";
const char S_passph[] PROGMEM = "********";
const char S_titlewifisaved[] PROGMEM = "WiFi 已保存";
const char S_titlewifisettings[] PROGMEM = "设置已保存";
const char S_titlewifi[] PROGMEM = "配置 WiFi";
const char S_titleinfo[] PROGMEM = "系统信息";
const char S_titleparam[] PROGMEM = "参数设置";
const char S_titleparamsaved[] PROGMEM = "设置已保存";
const char S_titleexit[] PROGMEM = "退出配网";
const char S_titlereset[] PROGMEM = "重置";
const char S_titleerase[] PROGMEM = "清除配置";
const char S_titleclose[] PROGMEM = "关闭页面";
const char S_options[] PROGMEM = "选项";
const char S_nonetworks[] PROGMEM = "未发现 WiFi 网络,请点击刷新重新扫描。";
const char S_staticip[] PROGMEM = "静态 IP";
const char S_staticgw[] PROGMEM = "静态网关";
const char S_staticdns[] PROGMEM = "静态 DNS";
const char S_subnet[] PROGMEM = "子网掩码";
const char S_exiting[] PROGMEM = "正在退出配网模式";
const char S_resetting[] PROGMEM = "设备将在几秒后重启";
const char S_closing[] PROGMEM = "可以关闭此页面,配网服务将继续运行";
const char S_error[] PROGMEM = "发生错误";
const char S_notfound[] PROGMEM = "页面未找到\n\n";
const char S_uri[] PROGMEM = "URI: ";
const char S_method[] PROGMEM = "\n请求方法: ";
const char S_args[] PROGMEM = "\n参数: ";
const char S_parampre[] PROGMEM = "param_";
// debug strings
const char D_HR[] PROGMEM = "--------------------";
// softap ssid default prefix
#ifdef ESP8266
const char S_ssidpre[] PROGMEM = "ESP";
#elif defined(ESP32)
const char S_ssidpre[] PROGMEM = "ESP32";
#else
const char S_ssidpre[] PROGMEM = "WM";
#endif
#endif

View File

@@ -9,6 +9,8 @@ framework = arduino
; 串口监视器
monitor_speed = 115200
monitor_filters = direct
monitor_rts = 0
monitor_dtr = 0
; Flash 配置
board_build.flash_mode = qio
@@ -25,9 +27,6 @@ build_flags =
; 库依赖
lib_deps =
; WiFi 配网 (自带 captive portal)
tzapu/WiFiManager@^2.0.17
; MQTT 客户端
knolleary/PubSubClient@^2.8
@@ -43,4 +42,4 @@ lib_deps =
; 上传配置
upload_speed = 921600
upload_port = COM15
upload_port = COM20

View File

@@ -1,13 +1,43 @@
/**
* @file alarm_manager.cpp
* @brief 报警管理器 - 双模式实现
* @brief 报警管理器 - 多类型分优先级实现
*
* 本地: 跌倒/水浸/心率异常/呼吸异常 → 立即蜂鸣器+LED
* 服务端: cmd/alarm, cmd/alarm/restore → 执行联动 + 回复响应
* 蜂鸣模式 (人身安全 > 环境 > 设备):
* 跌倒: 连速哔 (60ms/60ms) — 最急 ★★★★★
* 心率/呼吸: 5连急哔 (80ms/80ms) — 次急 ★★★★★
* 水浸: 3连哔 (120ms/100ms) — 中等 ★★★
* 温湿度: 2声长停 (200ms/400ms) — 温和 ★★
* 传感器: 单声长哔 (300ms/3s) — 提示 ★
*/
#include "alarm_manager.h"
// ============================================================
// 优先级定义 — 人身安全 > 环境安全 > 设备故障
// ============================================================
uint8_t AlarmManager::_priority(AlarmType t) {
switch (t) {
// ★★★★★ 人身安全 — 直接威胁生命
case ALARM_FALL: return 0;
case ALARM_HEART_ABNORMAL: return 1;
case ALARM_BREATH_ABNORMAL: return 1;
// ★★★ 环境安全 — 潜在危险
case ALARM_WATER_LEAK: return 2;
// ★★ 环境舒适 — 非紧急
case ALARM_TEMP_ABNORMAL: return 3;
case ALARM_HUMI_ABNORMAL: return 3;
// ★ 设备状态 — 仅提示
case ALARM_SENSOR_FAULT: return 4;
case ALARM_SERVER: return 5;
default: return 9;
}
}
// ============================================================
// 构造 & 析构
// ============================================================
AlarmManager::AlarmManager()
: _buzzerOn(false)
, _ledOn(false)
@@ -15,10 +45,16 @@ AlarmManager::AlarmManager()
, _buzzerToggleTime(0)
, _buzzerBeepState(false)
, _beepCount(0)
, _beepsPerCycle(3)
, _beepOnMs(150)
, _beepOffMs(100)
, _cyclePauseMs(600)
, _activeCount(0)
, _lastLEDToggle(0)
, _alarmCb(nullptr)
{
for (int i = 0; i < 4; i++) _activeAlarms[i] = ALARM_NONE;
for (uint8_t i = 0; i < MAX_ACTIVE; i++) _activeAlarms[i] = ALARM_NONE;
memset(_activeDetail, 0, sizeof(_activeDetail));
}
AlarmManager::~AlarmManager() {}
@@ -28,18 +64,14 @@ AlarmManager::~AlarmManager() {}
// ============================================================
bool AlarmManager::begin() {
ledcSetup(BUZZER_PWM_CHANNEL, BUZZER_PWM_FREQ, BUZZER_PWM_RESOLUTION);
ledcAttachPin(BUZZER_PIN, BUZZER_PWM_CHANNEL);
ledcWrite(BUZZER_PWM_CHANNEL, 0); // 关
// 有源蜂鸣器: 用 digitalWrite, 不用 PWM
pinMode(BUZZER_PIN, OUTPUT);
digitalWrite(BUZZER_PIN, LOW);
pinMode(LED_ALARM_PIN, OUTPUT);
digitalWrite(LED_ALARM_PIN, LOW);
_buzzerToggleTime = 0;
_buzzerBeepState = false;
_beepCount = 0;
Serial.println(F("[告警] ✅ 双模式报警 (本地+服务端)"));
Serial.println(F("[告警] ✅ 多类型分优先级报警就绪 (7种类型)"));
return true;
}
@@ -53,33 +85,129 @@ void AlarmManager::loop() {
}
// ============================================================
// 本地报警触发 (离线可用)
// 报警名称映射
// ============================================================
void AlarmManager::triggerLocal(AlarmType type) {
const char* AlarmManager::getAlarmName(AlarmType type) const {
switch (type) {
case ALARM_FALL: return "跌倒";
case ALARM_WATER_LEAK: return "水浸";
case ALARM_HEART_ABNORMAL: return "心率异常";
case ALARM_BREATH_ABNORMAL: return "呼吸异常";
case ALARM_TEMP_ABNORMAL: return "温度异常";
case ALARM_HUMI_ABNORMAL: return "湿度异常";
case ALARM_SENSOR_FAULT: return "传感器故障";
case ALARM_SERVER: return "云端指令";
default: return "未知";
}
}
// ============================================================
// 最高优先级报警
// ============================================================
AlarmManager::AlarmType AlarmManager::getHighestAlarm() const {
if (_activeCount == 0) return ALARM_NONE;
AlarmType best = ALARM_NONE;
uint8_t bestP = 99;
for (uint8_t i = 0; i < _activeCount; i++) {
uint8_t p = _priority(_activeAlarms[i]);
if (p < bestP) { bestP = p; best = _activeAlarms[i]; }
}
return best;
}
const char* AlarmManager::getActiveAlarmStr() const {
AlarmType t = getHighestAlarm();
if (t == ALARM_NONE) return "";
return getAlarmName(t);
}
// ============================================================
// 蜂鸣参数表 (按最高优先级报警驱动)
// ============================================================
uint32_t AlarmManager::_buzzerIntervalFor(AlarmType type) {
switch (type) {
case ALARM_FALL: return 200; // LED 快闪
case ALARM_WATER_LEAK: return 300;
case ALARM_HEART_ABNORMAL: return 500;
case ALARM_BREATH_ABNORMAL: return 500;
case ALARM_TEMP_ABNORMAL: return 800;
case ALARM_HUMI_ABNORMAL: return 800;
case ALARM_SENSOR_FAULT: return 1000;
default: return 500;
}
}
// ============================================================
// 更新蜂鸣参数 (根据当前最高优先级报警)
// ============================================================
static void _calcBuzzerParams(AlarmManager::AlarmType t,
uint8_t& beeps, uint32_t& onMs,
uint32_t& offMs, uint32_t& pauseMs) {
switch (t) {
// 人身安全 — 连速急哔
case AlarmManager::ALARM_FALL:
beeps = 0; onMs = 200; offMs = 200; pauseMs = 0; // 跌倒: 急促哔哔
break;
case AlarmManager::ALARM_HEART_ABNORMAL:
case AlarmManager::ALARM_BREATH_ABNORMAL:
beeps = 5; onMs = 200; offMs = 150; pauseMs = 500; // 心率/呼吸: 5连哔
break;
// 环境安全
case AlarmManager::ALARM_WATER_LEAK:
beeps = 3; onMs = 250; offMs = 200; pauseMs = 600; // 水浸: 3连哔
break;
// 环境舒适
case AlarmManager::ALARM_TEMP_ABNORMAL:
case AlarmManager::ALARM_HUMI_ABNORMAL:
beeps = 2; onMs = 300; offMs = 500; pauseMs = 1500; // 温湿度: 2声长停
break;
// 设备状态
case AlarmManager::ALARM_SENSOR_FAULT:
beeps = 1; onMs = 500; offMs = 3000; pauseMs = 0; // 传感器: 单声提醒
break;
default:
beeps = 0; onMs = 500; offMs = 500; pauseMs = 0; // 持续
break;
}
}
// ============================================================
// 本地报警触发
// ============================================================
void AlarmManager::triggerLocal(AlarmType type, const char* detail) {
// 去重
for (uint8_t i = 0; i < _activeCount; i++) {
if (_activeAlarms[i] == type) return;
}
if (_activeCount < 4) {
if (_activeCount < MAX_ACTIVE) {
_activeAlarms[_activeCount++] = type;
}
const char* name = "";
switch (type) {
case ALARM_FALL: name = "跌倒"; break;
case ALARM_WATER_LEAK: name = "水浸"; break;
case ALARM_HEART_ABNORMAL: name = "心率异常"; break;
case ALARM_BREATH_ABNORMAL: name = "呼吸异常"; break;
default: name = "未知"; break;
const char* name = getAlarmName(type);
Serial.printf("[告警] 🚨 触发: %s (活动:%d)", name, _activeCount);
if (detail && detail[0]) Serial.printf(" | %s", detail);
Serial.println();
// 更新蜂鸣参数 (按最高优先级)
AlarmType highest = getHighestAlarm();
_calcBuzzerParams(highest, _beepsPerCycle, _beepOnMs, _beepOffMs, _cyclePauseMs);
// 保存详情
if (detail) {
strncpy(_activeDetail, detail, sizeof(_activeDetail) - 1);
}
Serial.printf("[告警] 🚨 本地报警触发: %s (活动报警:%d)\n", name, _activeCount);
// 立即开启蜂鸣器和 LED
_setBuzzer(true);
_setLED(true);
// 通知外部
if (_alarmCb) _alarmCb((uint8_t)type, true, detail ? detail : name);
}
void AlarmManager::clearLocal(AlarmType type) {
@@ -94,21 +222,29 @@ void AlarmManager::clearLocal(AlarmType type) {
}
}
Serial.printf("[告警] 本地报警清除, 剩余活动:%d\n", _activeCount);
const char* name = getAlarmName(type);
Serial.printf("[告警] ✅ 清除: %s (剩余:%d)\n", name, _activeCount);
// 没有活动报警时关闭
if (_activeCount == 0) {
_setBuzzer(false);
_setLED(false);
_activeDetail[0] = '\0';
} else {
// 降级到次高优先级报警的蜂鸣参数
AlarmType highest = getHighestAlarm();
_calcBuzzerParams(highest, _beepsPerCycle, _beepOnMs, _beepOffMs, _cyclePauseMs);
}
if (_alarmCb) _alarmCb((uint8_t)type, false, name);
}
void AlarmManager::clearAllLocal() {
_activeCount = 0;
for (int i = 0; i < 4; i++) _activeAlarms[i] = ALARM_NONE;
for (uint8_t i = 0; i < MAX_ACTIVE; i++) _activeAlarms[i] = ALARM_NONE;
_activeDetail[0] = '\0';
_setBuzzer(false);
_setLED(false);
Serial.println(F("[告警] 清除所有本地报警"));
Serial.println(F("[告警] 清除所有报警"));
}
// ============================================================
@@ -116,16 +252,15 @@ void AlarmManager::clearAllLocal() {
// ============================================================
void AlarmManager::executeAlarm(const AlarmCommand& cmd) {
Serial.printf("[告警] ☁️ 服务端告警: %s (等级=%s, 蜂鸣=%d)\n",
Serial.printf("[告警] ☁️ 服务端: %s (等级=%s 蜂鸣=%d)\n",
cmd.alarmType, cmd.alarmLevel, cmd.buzzerOn);
// 映射 alarmType 到本地类型
AlarmType localType = ALARM_SERVER;
if (strcmp(cmd.alarmType, "fall") == 0) localType = ALARM_FALL;
else if (strcmp(cmd.alarmType, "water_leak") == 0) localType = ALARM_WATER_LEAK;
if (cmd.buzzerOn) {
triggerLocal(localType);
triggerLocal(localType, cmd.alarmDesc);
if (cmd.buzzerDuration > 0) {
_buzzerOffTime = millis() + (uint32_t)cmd.buzzerDuration * 1000;
} else {
@@ -138,25 +273,22 @@ void AlarmManager::executeAlarm(const AlarmCommand& cmd) {
}
}
// ============================================================
// 服务端恢复指令 (§5.2)
// ============================================================
void AlarmManager::executeRestore(const AlarmCommand& cmd) {
Serial.printf("[告警] ☁️ 服务端恢复: %s\n", cmd.alarmType);
Serial.printf("[告警] ☁️ 恢复: %s\n", cmd.alarmType);
AlarmType localType = ALARM_SERVER;
if (strcmp(cmd.alarmType, "fall") == 0) localType = ALARM_FALL;
else if (strcmp(cmd.alarmType, "water_leak") == 0) localType = ALARM_WATER_LEAK;
if (strcmp(cmd.alarmType, "fall") == 0) localType = ALARM_FALL;
else if (strcmp(cmd.alarmType, "water_leak") == 0) localType = ALARM_WATER_LEAK;
else if (strcmp(cmd.alarmType, "heart_abnormal") == 0) localType = ALARM_HEART_ABNORMAL;
else if (strcmp(cmd.alarmType, "breath_abnormal") == 0) localType = ALARM_BREATH_ABNORMAL;
else if (strcmp(cmd.alarmType, "temp_abnormal") == 0) localType = ALARM_TEMP_ABNORMAL;
else if (strcmp(cmd.alarmType, "humi_abnormal") == 0) localType = ALARM_HUMI_ABNORMAL;
else if (strcmp(cmd.alarmType, "sensor_fault") == 0) localType = ALARM_SENSOR_FAULT;
clearLocal(localType);
if (!cmd.buzzerOn) {
_setBuzzer(false);
}
if (!cmd.nightLight) {
_setLED(false);
}
if (!cmd.buzzerOn) _setBuzzer(false);
if (!cmd.nightLight) _setLED(false);
}
// ============================================================
@@ -164,27 +296,11 @@ void AlarmManager::executeRestore(const AlarmCommand& cmd) {
// ============================================================
void AlarmManager::buzzerTest(uint32_t durationSec) {
Serial.printf("[告警] 🔔 蜂鸣器测试 %d\n", durationSec);
Serial.printf("[告警] 🔔 蜂鸣器测试 %ds\n", durationSec);
_setBuzzer(true);
_buzzerOffTime = millis() + durationSec * 1000;
}
// ============================================================
// 状态查询
// ============================================================
const char* AlarmManager::getActiveAlarmType() const {
if (_activeCount == 0) return "";
switch (_activeAlarms[0]) {
case ALARM_FALL: return "fall";
case ALARM_WATER_LEAK: return "water_leak";
case ALARM_HEART_ABNORMAL: return "heart_abnormal";
case ALARM_BREATH_ABNORMAL: return "breath_abnormal";
case ALARM_SERVER: return "server";
default: return "";
}
}
// ============================================================
// 内部控制
// ============================================================
@@ -195,9 +311,9 @@ void AlarmManager::_setBuzzer(bool on) {
_buzzerToggleTime = 0;
_beepCount = 0;
if (on) {
_buzzerOffTime = 0; // 本地报警=无限哔哔
_buzzerOffTime = 0;
} else {
ledcWrite(BUZZER_PWM_CHANNEL, 0);
digitalWrite(BUZZER_PIN, LOW);
_buzzerOffTime = 0;
}
}
@@ -207,57 +323,76 @@ void AlarmManager::_setLED(bool on) {
if (!on) digitalWrite(LED_ALARM_PIN, LOW);
}
// ============================================================
// 蜂鸣器更新 (优先级驱动模式)
// ============================================================
void AlarmManager::_updateBuzzer() {
// 定时关闭检查
if (_buzzerOn && _buzzerOffTime > 0 && millis() >= _buzzerOffTime) {
if (!_buzzerOn) return;
// 定时关闭
if (_buzzerOffTime > 0 && millis() >= _buzzerOffTime) {
_buzzerOn = false;
ledcWrite(BUZZER_PWM_CHANNEL, 0);
digitalWrite(BUZZER_PIN, LOW);
Serial.println(F("[告警] ⏰ 蜂鸣器定时关闭"));
return;
}
// 本地报警: 三连哔 停 — 循环 (哔哔哔...哔哔哔...)
if (_buzzerOn && _buzzerOffTime == 0) {
uint32_t now = millis();
// 周期: 3声哔 (每声150ms响+100ms停) + 600ms大停顿
uint32_t now = millis();
// 云端定时: 哔哔声 (500ms响/500ms停), 不是长鸣
if (_buzzerOffTime > 0) {
if (now - _buzzerToggleTime >= 500) {
_buzzerToggleTime = now;
_buzzerBeepState = !_buzzerBeepState;
digitalWrite(BUZZER_PIN, _buzzerBeepState ? HIGH : LOW);
}
return;
}
// 本地报警: 按优先级模式 — 哔声/停顿交替
if (_beepsPerCycle == 0) {
// 连速哔 (跌倒): 简单翻转
if (now - _buzzerToggleTime >= (_buzzerBeepState ? _beepOnMs : _beepOffMs)) {
_buzzerToggleTime = now;
_buzzerBeepState = !_buzzerBeepState;
digitalWrite(BUZZER_PIN, _buzzerBeepState ? HIGH : LOW);
}
} else {
// 多声一组: N声哔 + 长停顿
if (!_buzzerBeepState) {
// 停顿中,检查是否该下一声
uint32_t pause = (_beepCount >= 3) ? 600 : 100;
// 停顿中
uint32_t pause = (_beepCount >= _beepsPerCycle) ? _cyclePauseMs : _beepOffMs;
if (now - _buzzerToggleTime >= pause) {
_buzzerToggleTime = now;
if (_beepCount >= 3) _beepCount = 0; // 重置周期
if (_beepCount >= _beepsPerCycle) _beepCount = 0;
_buzzerBeepState = true;
ledcWrite(BUZZER_PWM_CHANNEL, 128);
digitalWrite(BUZZER_PIN, HIGH);
_beepCount++;
}
} else {
// 在响中150ms后停下
if (now - _buzzerToggleTime >= 150) {
// 响声中
if (now - _buzzerToggleTime >= _beepOnMs) {
_buzzerToggleTime = now;
_buzzerBeepState = false;
ledcWrite(BUZZER_PWM_CHANNEL, 0);
digitalWrite(BUZZER_PIN, LOW);
}
}
}
// 云端定时: 持续响
else if (_buzzerOn && _buzzerOffTime > 0) {
ledcWrite(BUZZER_PWM_CHANNEL, 128);
}
}
// ============================================================
// LED 更新 (按最高优先级决定闪烁频率)
// ============================================================
void AlarmManager::_updateLED() {
if (!_ledOn) {
digitalWrite(LED_ALARM_PIN, LOW);
return;
}
// 根据告警类型调整闪烁频率
uint32_t interval = 500;
for (uint8_t i = 0; i < _activeCount; i++) {
if (_activeAlarms[i] == ALARM_FALL) {
interval = 200; // 跌倒快闪
break;
}
}
AlarmType highest = getHighestAlarm();
uint32_t interval = _buzzerIntervalFor(highest);
if (millis() - _lastLEDToggle >= interval) {
_lastLEDToggle = millis();

View File

@@ -1,6 +1,12 @@
/**
* @file button_manager.cpp
* @brief 三按钮管理器实现 - 去抖 + 长按/短按
* @brief 三按钮管理器实现 - 双向去抖 + 长按/短按 + 超时自愈
*
* 核心改进 (v2):
* 1. 按下沿和释放沿都经过 BTN_DEBOUNCE_MS 稳定确认
* 2. 长按期间持续检测 (不再等到松手才触发)
* 3. 按下超过 BTN_STUCK_TIMEOUT_MS 自动复位, 防止状态机卡死
* 4. 用 stableState 替代瞬时 raw 驱动状态, 消除噪声误触发
*/
#include "button_manager.h"
@@ -14,9 +20,19 @@ ButtonManager::ButtonManager()
, _cbDismiss(nullptr)
, _cbPage(nullptr)
{
_btnProvision = { BTN_PROVISION_PIN, false, 0, false };
_btnDismiss = { BTN_ALARM_DISMISS_PIN, false, 0, false };
_btnPage = { BTN_PAGE_SWITCH_PIN, false, 0, false };
auto initBtn = [](ButtonState& s, uint8_t pin) {
s.pin = pin;
s.stableState = false;
s.rawState = false;
s.changeTime = 0;
s.pressTime = 0;
s.longTriggered = false;
s.shortPending = false;
};
initBtn(_btnProvision, BTN_PROVISION_PIN);
initBtn(_btnDismiss, BTN_ALARM_DISMISS_PIN);
initBtn(_btnPage, BTN_PAGE_SWITCH_PIN);
}
// ============================================================
@@ -28,9 +44,19 @@ void ButtonManager::begin() {
pinMode(BTN_ALARM_DISMISS_PIN, INPUT_PULLUP);
pinMode(BTN_PAGE_SWITCH_PIN, INPUT_PULLUP);
Serial.println(F("[按钮] ✅ 三按钮初始化完成"));
// 强制初始化为未按下 (PCB 上电 GPIO 不稳, 忽略初始状态)
_btnProvision.rawState = false;
_btnProvision.stableState = false;
_btnDismiss.rawState = false;
_btnDismiss.stableState = false;
_btnPage.rawState = false;
_btnPage.stableState = false;
Serial.println(F("[按钮] ✅ 三按钮初始化完成 (双向去抖+超时自愈)"));
Serial.printf(" 配网: GPIO%d | 解除报警: GPIO%d | 翻页: GPIO%d\n",
BTN_PROVISION_PIN, BTN_ALARM_DISMISS_PIN, BTN_PAGE_SWITCH_PIN);
Serial.printf(" 去抖:%dms | 长按:%dms | 卡死超时:%dms\n",
BTN_DEBOUNCE_MS, BTN_LONG_PRESS_MS, BTN_STUCK_TIMEOUT_MS);
}
// ============================================================
@@ -38,6 +64,9 @@ void ButtonManager::begin() {
// ============================================================
void ButtonManager::loop() {
// 开机 10 秒静默 (PCB 上电 GPIO 不稳 + 强制初始未按下)
if (millis() < 10000) return;
// 按钮1: 配网 (仅长按)
_processButton(_btnProvision, false, true);
@@ -49,40 +78,91 @@ void ButtonManager::loop() {
}
// ============================================================
// 单按钮状态机
// 单按钮状态机 (基于稳定状态, 非瞬时边沿)
// ============================================================
void ButtonManager::_processButton(ButtonState& btn, bool hasShort, bool hasLong) {
bool raw = _readPin(btn.pin); // LOW = 按下
uint32_t now = millis();
bool raw = _readPin(btn.pin); // true = 按下 (LOW)
if (raw && !btn.pressed) {
// 按下沿
btn.pressed = true;
btn.pressTime = millis();
btn.longTriggered = false;
// ── 第1步: raw 状态变化时启动去抖计时 ──
if (raw != btn.rawState) {
btn.rawState = raw;
btn.changeTime = now;
// 不立即改变 stableState — 等稳定确认
}
else if (!raw && btn.pressed) {
// 松开沿
btn.pressed = false;
uint32_t holdTime = millis() - btn.pressTime;
if (holdTime < BTN_DEBOUNCE_MS) {
return; // 抖动脉冲,忽略
// ── 第2步: 去抖确认 — raw 保持稳定超过 BTN_DEBOUNCE_MS 才生效 ──
bool debounced = (now - btn.changeTime >= BTN_DEBOUNCE_MS);
bool stateChanged = (btn.rawState != btn.stableState);
if (debounced && stateChanged) {
// 状态正式切换
btn.stableState = btn.rawState;
if (btn.stableState) {
// ── 确认按下 ──
btn.pressTime = now;
btn.longTriggered = false;
btn.shortPending = hasShort; // 只有支持短按的按钮才标记待判定
} else {
// ── 确认释放 ──
uint32_t holdTime = now - btn.pressTime;
// 短按判定: 按下时长在去抖和长按阈值之间
if (btn.shortPending && holdTime >= BTN_DEBOUNCE_MS
&& holdTime < BTN_LONG_PRESS_MS && !btn.longTriggered) {
// 根据引脚分发短按回调
if (btn.pin == BTN_ALARM_DISMISS_PIN && _cbDismiss) {
_cbDismiss();
}
if (btn.pin == BTN_PAGE_SWITCH_PIN && _cbPage) {
_cbPage();
}
}
btn.shortPending = false;
btn.longTriggered = false;
}
}
if (hasLong && holdTime >= BTN_LONG_PRESS_MS && !btn.longTriggered) {
// 长按触发
// ── 第3步: 长按检测 (在按下稳定期间持续检查) ──
if (hasLong && btn.stableState && !btn.longTriggered) {
uint32_t holdTime = now - btn.pressTime;
if (holdTime >= BTN_LONG_PRESS_MS) {
btn.longTriggered = true;
if (_cbProvision) _cbProvision();
}
else if (hasShort && holdTime >= BTN_DEBOUNCE_MS && holdTime < BTN_LONG_PRESS_MS) {
// 短按触发
if (btn.pin == BTN_ALARM_DISMISS_PIN && _cbDismiss) _cbDismiss();
if (btn.pin == BTN_PAGE_SWITCH_PIN && _cbPage) _cbPage();
btn.shortPending = false; // 长按已触发, 不再判定短按
if (_cbProvision) {
_cbProvision();
}
}
}
// ── 第4步: 卡死自愈 — 按下超时强制复位 ──
if (btn.stableState && (now - btn.pressTime >= BTN_STUCK_TIMEOUT_MS)) {
Serial.printf("[按钮] ⚠️ GPIO%d 按下超时 %lums, 强制复位\n",
btn.pin, now - btn.pressTime);
_resetButton(btn);
}
}
// ============================================================
// 强制复位
// ============================================================
void ButtonManager::_resetButton(ButtonState& btn) {
btn.stableState = false;
btn.rawState = _readPin(btn.pin); // 重新同步真实硬件状态
btn.changeTime = 0;
btn.pressTime = 0;
btn.longTriggered = false;
btn.shortPending = false;
}
// ============================================================
// 读引脚 (INPUT_PULLUP → LOW = 按下)
// ============================================================
bool ButtonManager::_readPin(uint8_t pin) {
return digitalRead(pin) == LOW; // INPUT_PULLUP: LOW = 按下
return digitalRead(pin) == LOW;
}

View File

@@ -71,37 +71,63 @@ void DisplayManager::nextPage() {
// 主更新入口
// ============================================================
void DisplayManager::update(const TelemetryData& data, bool wifiOk, bool buzzerOn) {
void DisplayManager::update(const TelemetryData& data, bool wifiOk, bool buzzerOn,
bool hasAlarm, const char* alarmName) {
if (!_initialized) return;
// 每 5 秒刷新一次 (防止 OLED 烧屏,也避免 SPI 占用过多)
if (millis() - _lastDrawTime < 5000) return;
// 有报警时每秒刷新 (闪烁提示),否则 5 秒
uint32_t interval = hasAlarm ? 1000 : 5000;
if (millis() - _lastDrawTime < interval) return;
_lastDrawTime = millis();
_u8g2.clearBuffer();
// 活跃报警 — 所有页面顶部闪烁提示
if (hasAlarm && alarmName && alarmName[0]) {
_drawAlarmBanner(alarmName);
}
switch (_pageIndex) {
case 0: _drawPage0(data, wifiOk, buzzerOn); break;
case 1: _drawPage1(data); break;
case 2: _drawPage2(data); break;
case 3: _drawPage3(data, buzzerOn, wifiOk); break;
default: _drawPage0(data, wifiOk, buzzerOn); break;
case 0: _drawPage0(data, wifiOk, buzzerOn, hasAlarm); break;
case 1: _drawPage1(data, hasAlarm); break;
case 2: _drawPage2(data, hasAlarm); break;
case 3: _drawPage3(data, buzzerOn, wifiOk, hasAlarm); break;
default: _drawPage0(data, wifiOk, buzzerOn, hasAlarm); break;
}
_u8g2.sendBuffer();
}
// ============================================================
// 报警横幅 (顶部闪烁)
// ============================================================
void DisplayManager::_drawAlarmBanner(const char* name) {
// 每秒翻转,实现闪烁效果
bool blink = ((millis() / 1000) % 2 == 0);
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
if (blink) {
_u8g2.drawUTF8(2, 12, "🚨");
_u8g2.drawUTF8(26, 12, name);
} else {
_u8g2.drawUTF8(2, 12, " ");
}
_u8g2.drawHLine(0, 15, 128);
}
// ============================================================
// Page 0: 环境数据
// ============================================================
void DisplayManager::_drawPage0(const TelemetryData& data, bool wifiOk, bool buzzerOn) {
// 标题栏
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "🏥 健康监测 V1.0");
// 分隔线
_u8g2.drawHLine(0, 15, 128);
void DisplayManager::_drawPage0(const TelemetryData& data, bool wifiOk,
bool buzzerOn, bool hasAlarm) {
// 标题栏 (报警横幅已占顶部时跳过)
if (!hasAlarm) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "🏥 健康监测 V1.0");
_u8g2.drawHLine(0, 15, 128);
}
// 温湿度
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
@@ -144,11 +170,13 @@ void DisplayManager::_drawPage0(const TelemetryData& data, bool wifiOk, bool buz
// Page 1: 生命体征
// ============================================================
void DisplayManager::_drawPage1(const TelemetryData& data) {
void DisplayManager::_drawPage1(const TelemetryData& data, bool hasAlarm) {
// 标题栏
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "🫀 生命体征");
_u8g2.drawHLine(0, 15, 128);
if (!hasAlarm) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "🫀 生命体征");
_u8g2.drawHLine(0, 15, 128);
}
// 心率
_u8g2.drawUTF8(4, 32, "心率:");
@@ -182,10 +210,12 @@ void DisplayManager::_drawPage1(const TelemetryData& data) {
// Page 2: 人体状态
// ============================================================
void DisplayManager::_drawPage2(const TelemetryData& data) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "人体状态");
_u8g2.drawHLine(0, 15, 128);
void DisplayManager::_drawPage2(const TelemetryData& data, bool hasAlarm) {
if (!hasAlarm) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "人体状态");
_u8g2.drawHLine(0, 15, 128);
}
_u8g2.drawUTF8(4, 30, "有人:");
_u8g2.drawUTF8(60, 30, data.presence ? "V" : "X");
@@ -210,13 +240,20 @@ void DisplayManager::_drawPage2(const TelemetryData& data) {
// Page 3: 安全信息
// ============================================================
void DisplayManager::_drawPage3(const TelemetryData& data, bool buzzerOn, bool wifiOk) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "安全信息");
_u8g2.drawHLine(0, 15, 128);
void DisplayManager::_drawPage3(const TelemetryData& data, bool buzzerOn,
bool wifiOk, bool hasAlarm) {
if (!hasAlarm) {
_u8g2.setFont(u8g2_font_wqy12_t_gb2312);
_u8g2.drawUTF8(2, 12, "安全信息");
_u8g2.drawHLine(0, 15, 128);
}
_u8g2.drawUTF8(4, 30, "水浸:");
_u8g2.drawUTF8(60, 30, data.rainDO ? "正常" : "!!报警");
if (!data.rainValid) {
_u8g2.drawUTF8(60, 30, "离线");
} else {
_u8g2.drawUTF8(60, 30, data.rainDO ? "正常" : "!!报警");
}
_u8g2.drawUTF8(4, 44, "蜂鸣器:");
_u8g2.drawUTF8(60, 44, buzzerOn ? "" : "");

View File

@@ -36,10 +36,27 @@ uint32_t g_sequence = 1; // 上报序号
uint32_t g_bootTime = 0; // 启动时刻
// 本地报警跟踪 (防重复触发)
bool g_localFallActive = false;
bool g_localWaterActive = false;
bool g_localHeartAbnActive = false;
bool g_localBreathAbnActive = false;
bool g_localFallActive = false;
bool g_localWaterActive = false;
bool g_localHeartAbnActive = false;
bool g_localBreathAbnActive = false;
bool g_localTempAbnActive = false;
bool g_localHumiAbnActive = false;
bool g_localRadarFaultActive = false;
bool g_localDhtFaultActive = false;
// 报警防抖计数器 (连续N次确认才触发)
uint8_t g_tempAbnCnt = 0;
uint8_t g_humiAbnCnt = 0;
uint8_t g_radarTimeoutCnt = 0;
uint8_t g_heartAbnCnt = 0; // 心率异常防抖
uint8_t g_breathAbnCnt = 0; // 呼吸异常防抖
uint32_t g_fallDetectMs = 0; // 跌倒检测计时 (ms)
uint32_t g_fallClearMs = 0; // 跌倒清除计时 (ms)
bool g_fallDismissed = false; // 已手动解除, 不重复报警
#define ALARM_HYSTERESIS 3 // 连续3次确认才触发
#define FALL_DETECT_MS 2000 // 跌倒持续2秒才触发 (房顶安装, 过滤坐下)
#define FALL_CLEAR_MS 5000 // 跌倒消失5秒后才清除 (防误消)
// ============================================================
// 时间同步
@@ -74,14 +91,28 @@ void syncTime() {
void handleAlarmCommand(const AlarmCommand& cmd) {
if (strcmp(cmd.type, "alarm_restore") == 0) {
// 告警恢复
// 告警恢复 — 远程指令与手动按钮同等优先级
g_alarm.executeRestore(cmd);
// 清除对应本地告警状态
if (strcmp(cmd.alarmType, "fall") == 0) g_localFallActive = false;
else if (strcmp(cmd.alarmType, "water_leak") == 0) g_localWaterActive = false;
else if (strcmp(cmd.alarmType, "heart_abnormal") == 0) g_localHeartAbnActive = false;
else if (strcmp(cmd.alarmType, "breath_abnormal") == 0) g_localBreathAbnActive = false;
// 清除全部本地告警状态 (远程恢复=手动解除)
g_localFallActive = false;
g_localWaterActive = false;
g_localHeartAbnActive = false;
g_localBreathAbnActive = false;
g_localTempAbnActive = false;
g_localHumiAbnActive = false;
g_localRadarFaultActive = false;
g_localDhtFaultActive = false;
// 防抖计数器也复位
g_tempAbnCnt = 0;
g_humiAbnCnt = 0;
g_radarTimeoutCnt = 0;
g_heartAbnCnt = 0;
g_breathAbnCnt = 0;
g_fallDetectMs = 0;
g_fallClearMs = 0;
g_fallDismissed = false;
} else {
// 告警触发
g_alarm.executeAlarm(cmd);
@@ -191,11 +222,13 @@ TelemetryData buildTelemetry(const RadarRawData& radar) {
memset(&t, 0, sizeof(t));
// 设备标识
strncpy(t.saleid, SALEID_DEFAULT, sizeof(t.saleid) - 1);
strncpy(t.gateid, g_mqtt.getGateId(), sizeof(t.gateid) - 1);
strncpy(t.meterId, METER_ID_DEFAULT, sizeof(t.meterId) - 1);
strncpy(t.meterName, METER_NAME_DEFAULT, sizeof(t.meterName) - 1);
// 环境类型: 从配网配置读取 (默认 bedroom)
DeviceConfig cfg = g_wifi.getConfig();
strncpy(t.deviceType, cfg.env_type[0] ? cfg.env_type : "bedroom", sizeof(t.deviceType)-1);
// DHT11
t.temp = g_sensor.getTemp();
t.humi = g_sensor.getHumi();
@@ -209,6 +242,8 @@ TelemetryData buildTelemetry(const RadarRawData& radar) {
t.heartRate = radar.heartRate;
t.breathRate = radar.breathRate;
t.body = radar.body;
t.sleepState = radar.sleepState;
t.sleepScore = radar.sleepScore;
t.radarValid = radar.radarValid;
t.vitalValid = radar.vitalValid;
@@ -226,53 +261,188 @@ TelemetryData buildTelemetry(const RadarRawData& radar) {
}
// ============================================================
// 本地报警检查 (离线可用)
// 本地报警回调 → MQTT 上报 (连接时)
// ============================================================
void onLocalAlarmChange(uint8_t type, bool triggered, const char* desc) {
if (g_mqtt.isConnected()) {
g_mqtt.publishLocalAlarm(type, triggered, desc);
}
}
// ============================================================
// 本地报警检查 (离线可用) — 人身安全 > 环境 > 设备
// ============================================================
void checkLocalAlarms(const RadarRawData& radar) {
uint32_t now = millis();
// 环境类型 → 报警开关
const char* env = g_wifi.getConfig().env_type;
bool enFall = (strcmp(env, "bathroom") == 0 || strcmp(env, "hallway") == 0 || strcmp(env, "livingroom") == 0);
bool enVital = (strcmp(env, "bedroom") == 0);
bool enWater = (strcmp(env, "bathroom") == 0);
bool enEnv = (strcmp(env, "bedroom") == 0 || strcmp(env, "balcony") == 0 || strcmp(env, "kitchen") == 0);
// --- 跌倒检测 (本地紧急报警, 立即触发) ---
if (radar.fall && radar.radarValid && !g_localFallActive) {
g_localFallActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_FALL);
} else if (!radar.fall && g_localFallActive) {
g_localFallActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_FALL);
// ─── 前置条件: 有人才检测人体生命体征 ───
bool humanPresent = radar.presence && radar.radarValid;
// ─── ★★★★★ 人身安全: 跌倒 (仅浴室/走廊/客厅) ───
if (enFall && radar.fall && humanPresent && !g_fallDismissed && millis() > 30000) {
if (!g_localFallActive) {
// 持续检测中: 必须满 FALL_DETECT_MS 才报警
if (g_fallDetectMs == 0) {
g_fallDetectMs = millis();
} else if (millis() - g_fallDetectMs >= FALL_DETECT_MS) {
g_localFallActive = true;
g_fallDetectMs = 0;
g_fallClearMs = 0;
g_alarm.triggerLocal(AlarmManager::ALARM_FALL, "雷达检测到跌倒");
}
} else {
g_fallClearMs = 0; // 还在跌倒, 重置清除计时
}
} else {
g_fallDetectMs = 0;
// 解除抑制需要 fall 持续消失 FALL_CLEAR_MS (防 AFD1 频繁跳变)
if (g_fallDismissed && g_fallClearMs == 0) {
g_fallClearMs = millis();
}
if (!g_fallDismissed || millis() - g_fallClearMs >= FALL_CLEAR_MS) {
g_fallDismissed = false;
g_fallClearMs = 0;
}
if (g_localFallActive) {
// 跌倒已消失 → 延迟清除
if (g_fallClearMs == 0) {
g_fallClearMs = millis();
} else if (millis() - g_fallClearMs >= FALL_CLEAR_MS) {
g_localFallActive = false;
g_fallClearMs = 0;
g_alarm.clearLocal(AlarmManager::ALARM_FALL);
}
}
}
// --- 水浸检测 ---
// ─── ★★★★★ 人身安全: 心率异常 (仅卧室+ABD1, 防抖3次, 30s) ───
#if RADAR_TYPE != RADAR_TYPE_AFD1
if (enVital && millis() > 30000 && humanPresent && radar.vitalValid && radar.heartRate > 0) {
bool heartAbn = (radar.heartRate < HEART_RATE_MIN || radar.heartRate > HEART_RATE_MAX);
if (heartAbn) {
if (++g_heartAbnCnt >= ALARM_HYSTERESIS && !g_localHeartAbnActive) {
g_localHeartAbnActive = true;
char buf[32];
snprintf(buf, sizeof(buf), "心率异常: %d bpm", radar.heartRate);
g_alarm.triggerLocal(AlarmManager::ALARM_HEART_ABNORMAL, buf);
}
} else {
g_heartAbnCnt = 0;
if (g_localHeartAbnActive) {
g_localHeartAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_HEART_ABNORMAL);
}
}
} else if (!humanPresent && g_localHeartAbnActive) {
g_localHeartAbnActive = false;
g_heartAbnCnt = 0;
g_alarm.clearLocal(AlarmManager::ALARM_HEART_ABNORMAL);
}
#endif // RADAR_TYPE != AFD1
// ─── ★★★★★ 人身安全: 呼吸异常 (仅卧室+ABD1, 防抖3次, 30秒后生效) ───
#if RADAR_TYPE != RADAR_TYPE_AFD1
if (enVital && millis() > 30000 && humanPresent && radar.vitalValid && radar.breathRate > 0) {
bool breathAbn = (radar.breathRate < BREATH_RATE_MIN || radar.breathRate > BREATH_RATE_MAX);
if (breathAbn) {
if (++g_breathAbnCnt >= ALARM_HYSTERESIS && !g_localBreathAbnActive) {
g_localBreathAbnActive = true;
char buf[32];
snprintf(buf, sizeof(buf), "呼吸异常: %d 次/分", radar.breathRate);
g_alarm.triggerLocal(AlarmManager::ALARM_BREATH_ABNORMAL, buf);
}
} else {
g_breathAbnCnt = 0;
if (g_localBreathAbnActive) {
g_localBreathAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
}
}
} else if (!humanPresent && g_localBreathAbnActive) {
g_localBreathAbnActive = false;
g_breathAbnCnt = 0;
g_alarm.clearLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
}
#endif // RADAR_TYPE != AFD1
// ─── ★★★ 环境安全: 水浸 (仅浴室) ───
if (enWater) {
bool waterLeak = !g_sensor.getRainDO() && g_sensor.isRainValid();
if (waterLeak && !g_localWaterActive) {
g_localWaterActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_WATER_LEAK);
g_alarm.triggerLocal(AlarmManager::ALARM_WATER_LEAK, "水浸传感器触发");
} else if (!waterLeak && g_localWaterActive) {
g_localWaterActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_WATER_LEAK);
}
} // enWater
// --- 心率异常 (离线本地判定, 阈值预留后续调整) ---
if (radar.vitalValid && radar.heartRate > 0) {
bool heartAbn = (radar.heartRate < HEART_RATE_MIN || radar.heartRate > HEART_RATE_MAX);
if (heartAbn && !g_localHeartAbnActive) {
g_localHeartAbnActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_HEART_ABNORMAL);
} else if (!heartAbn && g_localHeartAbnActive) {
g_localHeartAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_HEART_ABNORMAL);
// ─── ★★ 环境舒适: 温湿度异常 (仅卧室/阳台/厨房, 防抖3次, 30s) ───
if (enEnv && millis() > 30000 && g_sensor.isDhtValid()) {
float temp = g_sensor.getTemp();
bool tempAbn = (temp < TEMP_LOW || temp > TEMP_HIGH);
if (tempAbn) {
if (++g_tempAbnCnt >= ALARM_HYSTERESIS && !g_localTempAbnActive) {
g_localTempAbnActive = true;
char buf[32];
snprintf(buf, sizeof(buf), "温度%.1f°C 超出范围", temp);
g_alarm.triggerLocal(AlarmManager::ALARM_TEMP_ABNORMAL, buf);
}
} else {
g_tempAbnCnt = 0;
if (g_localTempAbnActive) {
g_localTempAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_TEMP_ABNORMAL);
}
}
// ─── ★★ 环境舒适: 湿度异常 (防抖3次) ───
float humi = g_sensor.getHumi();
bool humiAbn = (humi < HUMI_LOW || humi > HUMI_HIGH);
if (humiAbn) {
if (++g_humiAbnCnt >= ALARM_HYSTERESIS && !g_localHumiAbnActive) {
g_localHumiAbnActive = true;
char buf[32];
snprintf(buf, sizeof(buf), "湿度%.1f%% 超出范围", humi);
g_alarm.triggerLocal(AlarmManager::ALARM_HUMI_ABNORMAL, buf);
}
} else {
g_humiAbnCnt = 0;
if (g_localHumiAbnActive) {
g_localHumiAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_HUMI_ABNORMAL);
}
}
}
// --- 呼吸异常 ---
if (radar.vitalValid && radar.breathRate > 0) {
bool breathAbn = (radar.breathRate < BREATH_RATE_MIN || radar.breathRate > BREATH_RATE_MAX);
if (breathAbn && !g_localBreathAbnActive) {
g_localBreathAbnActive = true;
g_alarm.triggerLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
} else if (!breathAbn && g_localBreathAbnActive) {
g_localBreathAbnActive = false;
g_alarm.clearLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
// ─── 设备:雷达超时 (连续5秒无数据, 仅标记, 不报警) ───
if (g_radar.isTimeout()) {
if (++g_radarTimeoutCnt >= 5 && !g_localRadarFaultActive) {
g_localRadarFaultActive = true;
Serial.println(F("[系统] ⚠️ 雷达超时无数据"));
}
} else {
g_radarTimeoutCnt = 0;
if (g_localRadarFaultActive) {
g_localRadarFaultActive = false;
Serial.println(F("[系统] ✅ 雷达数据恢复"));
}
}
// ─── 设备DHT 持续故障 (仅标记, 不报警) ───
if (g_sensor.isDhtFault() && !g_localDhtFaultActive && millis() > 60000) {
g_localDhtFaultActive = true;
Serial.println(F("[系统] ⚠️ DHT11 温湿度传感器故障"));
} else if (!g_sensor.isDhtFault() && g_localDhtFaultActive) {
g_localDhtFaultActive = false;
Serial.println(F("[系统] ✅ DHT11 传感器恢复"));
}
}
@@ -346,6 +516,20 @@ void setup() {
});
g_buttons.onAlarmDismiss([]() {
g_alarm.clearAllLocal();
// 同步清除所有本地状态标记
g_localFallActive = false;
g_localWaterActive = false;
g_localHeartAbnActive = false;
g_localBreathAbnActive = false;
g_localTempAbnActive = false;
g_localHumiAbnActive = false;
g_localRadarFaultActive = false;
g_localDhtFaultActive = false;
g_tempAbnCnt = 0;
g_humiAbnCnt = 0;
g_radarTimeoutCnt = 0;
g_fallClearMs = 0;
g_fallDismissed = true; // 抑制重复报警, 等跌倒信号消了再恢复
});
g_buttons.onProvisionLongPress([]() {
g_wifi.startConfigPortal();
@@ -370,6 +554,9 @@ void setup() {
g_mqtt.onControlCommand(handleControlCommand);
g_mqtt.onProvisionCommand(handleProvisionCommand);
// 注册本地报警回调 → MQTT 上报
g_alarm.onAlarmChange(onLocalAlarmChange);
// 连接 MQTT
if (g_wifi.isConnected()) {
g_mqtt.connect();
@@ -378,6 +565,9 @@ void setup() {
// 8. 传感器
g_sensor.begin();
// 8. 等雷达 + 电源稳定
delay(8000);
// 9. 雷达任务 (核心0)
xTaskCreatePinnedToCore(
radarTask, "RadarTask",
@@ -411,13 +601,22 @@ void loop() {
static bool wasDisconnected = true;
if (g_mqtt.isConnected()) {
if (wasDisconnected) {
// 刚恢复连接, 同步时间
syncTime();
wasDisconnected = false;
}
} else {
wasDisconnected = true;
}
// 配网成功后: 先更新 MQTT 配置, 再强制重连
if (g_wifi.justProvisioned()) {
DeviceConfig cfg = g_wifi.getConfig();
g_mqtt.disconnect();
g_mqtt.setServer(cfg.mqtt_server, atoi(cfg.mqtt_port),
cfg.mqtt_user, cfg.mqtt_pass);
g_mqtt.connect();
g_mqtt.publishProvisionStatus("success", "");
}
}
// --- 3. 传感器 (独立,任何传感器故障不影响其他) ---
@@ -427,33 +626,57 @@ void loop() {
RadarRawData radar = g_radar.getData();
checkLocalAlarms(radar);
// --- 传感器故障检测 (每30秒检查一次避免刷屏) ---
static uint32_t lastFaultCheck = 0;
if (now - lastFaultCheck > 30000) {
lastFaultCheck = now;
// --- 传感器状态日志 (仅记录, 不报警) ---
static uint32_t lastFaultLog = 0;
if (now - lastFaultLog > 60000) {
lastFaultLog = now;
if (!g_sensor.isDhtValid() && millis() > 60000) {
Serial.println(F("[系统] ⚠️ DHT11 未检测到 (不影响运行)"));
Serial.println(F("[系统] ⚠️ DHT11 读数失效"));
}
if (g_radar.isTimeout()) {
Serial.println(F("[系统] ⚠️ 雷达超时无数据 (不影响运行)"));
Serial.println(F("[系统] ⚠️ 雷达数据超时"));
}
}
// --- 5. 报警模块更新 (定时器/闪烁) ---
g_alarm.loop();
// --- 6. 遥测上报 (1秒周期) ---
if (now - g_lastReportTime >= REPORT_INTERVAL_MS) {
g_lastReportTime = now;
// --- 6. 遥测上报 ---
// 有人时: 每秒全量上报
// 无人时: 仅状态变化时发极简报文 + 60秒心跳 (省4G流量)
static bool lastPresence = false;
static uint32_t lastHeartbeat = 0; // 无人心跳计时
if (radar.presence) {
// ── 有人: 每秒完整上报 ──
if (now - g_lastReportTime >= REPORT_INTERVAL_MS) {
g_lastReportTime = now;
TelemetryData tele = buildTelemetry(radar);
g_mqtt.publishAllPoints(tele, g_sequence);
g_sequence++;
lastHeartbeat = now; // 有人时重置心跳计时
printDebug(tele);
g_display.update(tele, wifiOk, g_alarm.isBuzzerOn(),
g_alarm.hasActiveAlarm(), g_alarm.getActiveAlarmStr());
}
} else {
// ── 无人: 状态翻转发一次极简报文, 之后每60秒心跳 ──
if (lastPresence != radar.presence || now - lastHeartbeat >= PRESENCE_HEARTBEAT_MS) {
g_mqtt.publishPresenceOnly(radar.presence, radar.radarValid, g_sequence);
g_sequence++;
lastHeartbeat = now;
// 调试输出
Serial.printf("[上报] 无人极简报文 (Presence=0) 序号:%d\n", g_sequence - 1);
}
// OLED 还是照常刷新 (显示无人状态)
TelemetryData tele = buildTelemetry(radar);
g_mqtt.publishAllPoints(tele, g_sequence);
g_sequence++;
// 调试输出 + OLED 显示
printDebug(tele);
g_display.update(tele, wifiOk, g_alarm.isBuzzerOn());
g_display.update(tele, wifiOk, g_alarm.isBuzzerOn(),
g_alarm.hasActiveAlarm(), g_alarm.getActiveAlarmStr());
}
lastPresence = radar.presence;
// --- 7. MQTT 配网状态 (WiFiManager 触发时) ---
// WiFiManager 的 captive portal 启动/关闭时更新状态

View File

@@ -178,20 +178,23 @@ void MQTTManager::publishAllPoints(const TelemetryData& d, uint32_t sequence) {
snprintf(timeStr, sizeof(timeStr), "2026-01-01 00:00:00");
}
// 构建 JSON (协议规范 §4.1)
// 构建 JSON (协议规范 V0.08)
JsonDocument doc;
doc["saleid"] = d.saleid;
doc["gateid"] = d.gateid;
doc["type"] = "report";
doc["time"] = timeStr;
doc["sequence"] = String(sequence);
doc["source"] = "da";
// meter.id = gateid + 后缀 (如 10100001001 → 10100001001001)
String meterId = String(d.gateid) + "001";
JsonArray meter = doc["meter"].to<JsonArray>();
JsonObject m = meter.add<JsonObject>();
m["id"] = d.meterId;
m["id"] = meterId;
m["status"] = "1";
m["name"] = d.meterName;
m["type"] = d.deviceType; // 设备类型枚举
JsonObject v = m["values"].to<JsonObject>();
// 环境数据
@@ -205,6 +208,8 @@ void MQTTManager::publishAllPoints(const TelemetryData& d, uint32_t sequence) {
v["Resident"] = d.resident ? "1" : "0";
// 生命体征
v["Body"] = String(d.body);
v["SleepState"] = String(d.sleepState);
v["SleepScore"] = String(d.sleepScore);
v["HeartRate"] = String(d.heartRate);
v["BreathRate"] = String(d.breathRate);
v["RadarValid"] = d.radarValid ? "1" : "0";
@@ -371,6 +376,92 @@ void MQTTManager::publishProvisionResponse(const char* cmdId, bool success,
_publish(MQTT_TOPIC_RESPONSE_PROVISION, payload.c_str(), STATUS_QOS);
}
// ============================================================
// 上行: 本地报警上报 (§4.7 扩展)
// 设备本地判定报警后立即上报,格式兼容服务端告警协议
// ============================================================
void MQTTManager::publishLocalAlarm(uint8_t alarmType, bool triggered, const char* desc) {
if (!isConnected()) return;
struct tm timeinfo;
char timeStr[24];
if (getLocalTime(&timeinfo)) {
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
} else {
snprintf(timeStr, sizeof(timeStr), "2026-01-01 00:00:00");
}
// 类型映射
const char* typeStr = "unknown";
switch (alarmType) {
case 1: typeStr = "fall"; break; // ALARM_FALL
case 2: typeStr = "water_leak"; break; // ALARM_WATER_LEAK
case 3: typeStr = "heart_abnormal"; break; // ALARM_HEART_ABNORMAL
case 4: typeStr = "breath_abnormal"; break; // ALARM_BREATH_ABNORMAL
case 5: typeStr = "temp_abnormal"; break; // ALARM_TEMP_ABNORMAL
case 6: typeStr = "humi_abnormal"; break; // ALARM_HUMI_ABNORMAL
case 7: typeStr = "sensor_fault"; break; // ALARM_SENSOR_FAULT
default: typeStr = "unknown"; break;
}
JsonDocument doc;
doc["gateid"] = _gateid;
doc["type"] = triggered ? "alarm" : "alarm_restore";
doc["time"] = timeStr;
doc["alarmType"] = typeStr;
doc["source"] = "local"; // 标识本地判定
doc["desc"] = desc ? desc : "";
String payload;
serializeJson(doc, payload);
// 通过 status 通道上报本地报警
String topic = _buildTopic("status/%s/alarm");
_mqtt.publish(topic.c_str(), payload.c_str());
}
// ============================================================
// 上行: 无人极简报文 (省4G流量, 仅 Presence + RadarValid)
// ============================================================
void MQTTManager::publishPresenceOnly(bool presence, bool radarValid, uint32_t sequence) {
if (!isConnected()) return;
struct tm timeinfo;
char timeStr[24];
if (getLocalTime(&timeinfo)) {
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", &timeinfo);
} else {
snprintf(timeStr, sizeof(timeStr), "2026-01-01 00:00:00");
}
// 极简 JSON: ~120 字节 vs 完整报文 ~500 字节
JsonDocument doc;
doc["gateid"] = _gateid;
doc["type"] = "report";
doc["time"] = timeStr;
doc["sequence"] = String(sequence);
doc["source"] = "da";
String meterId = String(_gateid) + "001";
JsonArray meter = doc["meter"].to<JsonArray>();
JsonObject m = meter.add<JsonObject>();
m["id"] = meterId;
m["status"] = "1";
m["name"] = METER_NAME_DEFAULT;
JsonObject v = m["values"].to<JsonObject>();
v["Presence"] = presence ? "1" : "0";
v["RadarValid"] = radarValid ? "1" : "0";
String payload;
serializeJson(doc, payload);
_mqtt.publish("report/allpoints", payload.c_str());
}
// ============================================================
// 回调注册
// ============================================================

View File

@@ -19,6 +19,11 @@ RadarManager::RadarManager()
, _callback(nullptr)
, _frameCount(0)
, _errorCount(0)
, _lastHeartBeat(0)
, _lastBreathBeat(0)
, _stablePresence(false)
, _presenceCnt(0)
, _lastRawPresence(false)
{
memset(&_currentFrame, 0, sizeof(_currentFrame));
memset(&_data, 0, sizeof(_data));
@@ -43,6 +48,9 @@ bool RadarManager::begin() {
_resetParser();
memset(&_data, 0, sizeof(_data));
_data.lastUpdate = millis();
_stablePresence = false;
_presenceCnt = 0;
_lastRawPresence = false;
// 不给雷达发任何命令,让它自己初始化并开始自动上报
// R60ABD1 上电后通常需要 15-30 秒自检,之后自动输出数据
@@ -68,6 +76,16 @@ void RadarManager::loop() {
Serial.printf("[雷达] ✅ 开始接收数据 (首帧)\n");
}
// 心率/呼吸数据过期 (>10秒无更新 → 标记无效)
if (millis() - _lastHeartBeat > 10000) {
_data.vitalValid = false;
_data.heartRate = 0;
}
if (millis() - _lastBreathBeat > 10000) {
_data.vitalValid = false;
_data.breathRate = 0;
}
// 超时检测:如果 10 秒还没收到任何数据,打印一次
static uint32_t noDataWarn = 0;
if (_frameCount == 0 && millis() - _data.lastUpdate > 10000) {
@@ -222,6 +240,9 @@ void RadarManager::_processCompleteFrame() {
case CTRL_HEART_RATE:
_extractHeartRate(_currentFrame);
break;
case CTRL_FALL:
_extractFall(_currentFrame); // AFD1 专用跌倒
break;
case CTRL_SLEEP:
_extractSleep(_currentFrame);
break;
@@ -250,47 +271,60 @@ void RadarManager::_processCompleteFrame() {
void RadarManager::_extractPresence(const RadarFrame& frame) {
uint8_t raw = frame.data_length > 0 ? frame.data[0] : 0;
bool rawPresence = false;
// cmd 0x00 (len=1): 有人状态 (1=有人 0=无人)
if (frame.command == 0x00 && frame.data_length >= 1) {
_data.presence = (raw == 1);
rawPresence = (raw == 1);
_data.radarValid = true;
_data.lastUpdate = millis();
_notifyUpdate();
}
// cmd 0x01 (len=1): 运动状态/体动 (值越大越活跃)
else if (frame.command == 0x01 && frame.data_length >= 1) {
_data.body = raw; // 0=静止, 1+=有体动
_data.motion = (raw >= 1); // >=1 有运动
_data.body = raw;
_data.motion = (raw >= 1);
_data.radarValid = true;
_data.lastUpdate = millis();
_notifyUpdate();
return; // 运动帧不含有人信息, 跳过防抖
}
// cmd 0x03 (len=1): 有人状态备用 (非0=有人)
else if (frame.command == 0x03 && frame.data_length >= 1) {
_data.presence = (raw != 0);
rawPresence = (raw != 0);
_data.radarValid = true;
_data.lastUpdate = millis();
_notifyUpdate();
}
// cmd 0x04 (len=2): [0]=presence [1]=motion
else if (frame.command == 0x04 && frame.data_length >= 2) {
_data.presence = (frame.data[0] != 0);
_data.motion = (frame.data[1] == 0);
rawPresence = (frame.data[0] != 0);
_data.motion = (frame.data[1] == 0);
_data.radarValid = true;
_data.lastUpdate = millis();
_notifyUpdate();
}
// cmd 0x05 (len=6): 完整包
else if (frame.command == 0x05 && frame.data_length >= 6) {
_data.presence = (frame.data[0] != 0);
rawPresence = (frame.data[0] != 0);
_data.motion = (frame.data[1] == 0);
_data.distance = ((uint16_t)frame.data[2] << 8) | frame.data[3];
_data.body = frame.data[4];
_data.radarValid = true;
_data.lastUpdate = millis();
_notifyUpdate();
} else {
return; // 未知命令
}
// 防抖: 连续 PRESENCE_HYSTERESIS 次一致才更新
if (rawPresence == _lastRawPresence) {
if (_presenceCnt < PRESENCE_HYSTERESIS) _presenceCnt++;
} else {
_presenceCnt = 0;
_lastRawPresence = rawPresence;
}
if (_presenceCnt >= PRESENCE_HYSTERESIS && rawPresence != _data.presence) {
_data.presence = rawPresence;
_stablePresence = rawPresence;
}
_notifyUpdate();
}
void RadarManager::_extractBreathing(const RadarFrame& frame) {
@@ -299,8 +333,9 @@ void RadarManager::_extractBreathing(const RadarFrame& frame) {
if (rate != 0x80 && rate != 0x00 && rate <= 40) {
_data.breathRate = rate;
_data.vitalValid = true;
_data.presence = true; // 有呼吸=有人
_data.presence = true;
_data.radarValid = true;
_lastBreathBeat = millis();
}
_data.lastUpdate = millis();
_notifyUpdate();
@@ -313,14 +348,25 @@ void RadarManager::_extractHeartRate(const RadarFrame& frame) {
if (rate != 0x80 && rate != 0x00 && rate >= 40) {
_data.heartRate = rate;
_data.vitalValid = true;
_data.presence = true; // 有心跳=有人
_data.presence = true;
_data.radarValid = true;
_lastHeartBeat = millis();
}
_data.lastUpdate = millis();
_notifyUpdate();
}
}
// AFD1 专用: 跌倒检测 (控制字 0x83)
void RadarManager::_extractFall(const RadarFrame& frame) {
if (frame.data_length >= 1) {
_data.fall = (frame.data[0] != 0);
_data.radarValid = true;
_data.lastUpdate = millis();
_notifyUpdate();
}
}
void RadarManager::_extractSleep(const RadarFrame& frame) {
// 从睡眠数据提取: cmd=0x00/0x01 为入床离床状态
if (frame.data_length >= 1) {
@@ -333,16 +379,15 @@ void RadarManager::_extractSleep(const RadarFrame& frame) {
}
if (frame.data_length >= 3) {
// data[0]: 入床/离床
// data[1]: 睡眠状态
// data[2]: 评分
// 跌倒检测: 可能在睡眠异常标志中
if (frame.data_length >= 4) {
_data.fall = (frame.data[3] & 0x01) != 0;
_data.resident = (frame.data[3] & 0x02) != 0;
}
_data.sleepState = frame.data[1]; // 0=清醒 1=浅睡 2=深睡
_data.sleepScore = frame.data[2]; // 0~100
_data.lastUpdate = millis();
_notifyUpdate();
}
if (frame.data_length >= 4) {
_data.fall = (frame.data[3] & 0x01) != 0;
_data.resident = (frame.data[3] & 0x02) != 0;
_data.lastUpdate = millis();
_notifyUpdate();
}

View File

@@ -39,7 +39,7 @@ bool SensorManager::begin() {
pinMode(WATER_SENSOR_PIN, INPUT_PULLUP);
_lastRainRaw = (digitalRead(WATER_SENSOR_PIN) == HIGH); // HIGH=正常
_rainDO = _lastRainRaw;
_rainValid = true;
_rainValid = false; // 默认离线,直到检测到真正的状态变化才设为有效
// 首次读取 (DHT11 可能失败)
read();

View File

@@ -1,36 +1,42 @@
/**
* @file wifi_manager.cpp
* @brief WiFi 配网管理器实现
* @brief WiFi 配网管理器 — AP + WebServer
*/
#include "wifi_manager.h"
// 单例指针
// BLE 配网回调: 小程序写入 JSON → 仅在 BLE 栈中暂存, 不直接处理
class BLEProvisionCB : public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic* ch) override {
std::string val = ch->getValue();
if (val.length() > 0 && WiFiMgmt::_instance) {
Serial.printf("[BLE] 收到配网数据 (%d 字节)\n", val.length());
// 仅暂存 → 在 loop() 里处理 (BTC_TASK 栈太小, 写 Flash 会栈溢出)
WiFiMgmt::_instance->enqueueBLEJson(val.c_str());
}
}
};
WiFiMgmt* WiFiMgmt::_instance = nullptr;
// ============================================================
// 构造函数 & 析构函数
// ============================================================
WiFiMgmt::WiFiMgmt()
: _connected(false)
, _lastCheckTime(0)
, _buttonPressTime(0)
, _buttonPressed(false)
, _ledLastToggle(0)
, _ledState(false)
, _param_mqtt_server(nullptr)
, _param_mqtt_port(nullptr)
, _param_mqtt_user(nullptr)
, _param_mqtt_pass(nullptr)
, _param_device_id(nullptr)
, _param_env_note(nullptr)
: _server(nullptr), _dns(nullptr)
, _connected(false)
, _portalActive(false), _portalStopFlag(false)
, _portalStartTime(0)
, _lastCheckTime(0), _buttonPressTime(0), _buttonPressed(false)
, _lastScanTime(0)
, _provisionSuccess(false)
, _ledTaskHandle(nullptr), _ledMode(0)
, _bleServer(nullptr), _bleInfoChar(nullptr), _bleChar(nullptr), _bleAdv(nullptr)
, _blePendingFlag(false)
{
memset(&_config, 0, sizeof(_config));
_instance = this; // 设置单例
_instance = this;
}
WiFiMgmt::~WiFiMgmt() {
_stopPortal();
_instance = nullptr;
}
@@ -39,93 +45,53 @@ WiFiMgmt::~WiFiMgmt() {
// ============================================================
bool WiFiMgmt::begin() {
Serial.println(F("\n========================================"));
Serial.println(F(" WiFi 配网管理器 初始化"));
Serial.println(F("========================================"));
Serial.println(("\n=== WiFi 配网管理器 (SmartConfig) ==="));
// 初始化引脚
pinMode(CONFIG_BUTTON_PIN, INPUT_PULLUP);
pinMode(LED_WIFI_PIN, OUTPUT);
digitalWrite(LED_WIFI_PIN, LOW);
// 初始化 LittleFS
xTaskCreate(_ledTaskFunc, "WiFiLed", 1024, nullptr, 1, &_ledTaskHandle);
// 冷启动延迟, 等 Flash 稳定
delay(500);
if (!LittleFS.begin(true)) {
Serial.println(F("[WiFi] LittleFS 挂载失败!"));
Serial.println(("[WiFi] LittleFS 挂载失败!"));
return false;
}
Serial.println(F("[WiFi] LittleFS 已挂载"));
Serial.println(("[WiFi] LittleFS 就绪"));
// 加载配置
if (!_loadConfig()) {
Serial.println(F("[WiFi] 使用默认配置"));
// 设置默认值
strncpy(_config.mqtt_server, MQTT_DEFAULT_SERVER, sizeof(_config.mqtt_server) - 1);
strncpy(_config.mqtt_port, MQTT_DEFAULT_PORT_STR, sizeof(_config.mqtt_port) - 1);
strncpy(_config.mqtt_user, MQTT_DEFAULT_USER, sizeof(_config.mqtt_user) - 1);
strncpy(_config.mqtt_pass, MQTT_DEFAULT_PASS, sizeof(_config.mqtt_pass) - 1);
strncpy(_config.device_id, MQTT_DEFAULT_DEVICE, sizeof(_config.device_id) - 1);
strncpy(_config.env_note, MQTT_DEFAULT_ENV, sizeof(_config.env_note) - 1);
Serial.println(("[WiFi] 使用默认配置"));
strncpy(_config.mqtt_server, MQTT_DEFAULT_SERVER, sizeof(_config.mqtt_server)-1);
strncpy(_config.mqtt_port, MQTT_DEFAULT_PORT_STR, sizeof(_config.mqtt_port)-1);
strncpy(_config.mqtt_user, MQTT_DEFAULT_USER, sizeof(_config.mqtt_user)-1);
strncpy(_config.mqtt_pass, MQTT_DEFAULT_PASS, sizeof(_config.mqtt_pass)-1);
strncpy(_config.device_id, MQTT_DEFAULT_DEVICE, sizeof(_config.device_id)-1);
strncpy(_config.env_note, MQTT_DEFAULT_ENV, sizeof(_config.env_note)-1);
strncpy(_config.env_type, "bedroom", sizeof(_config.env_type)-1);
memset(_config.wifi_ssid, 0, sizeof(_config.wifi_ssid));
memset(_config.wifi_pass, 0, sizeof(_config.wifi_pass));
}
// 配置 WiFiManager
_wm.setDebugOutput(false); // 关闭调试输出 (避免干扰)
_wm.setConfigPortalTimeout(WIFI_CONFIG_TIMEOUT);
_wm.setConnectTimeout(WIFI_CONNECT_TIMEOUT);
_wm.setTitle("🏥 五劳无感康养监测 - 设备配网"); // 配网页面的中文标题
_wm.setCustomHeadElement(
"<style>"
"body { font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; }"
".msg .info { font-size: 14px; }"
"</style>"
);
WiFi.setAutoReconnect(true); // ESP32 自动重连
WiFi.setAutoReconnect(true);
// 注册保存回调 (必须使用静态函数, 不能使用 lambda)
_wm.setSaveConfigCallback(_saveParamsCallback);
// 创建自定义参数
_param_mqtt_server = new WiFiManagerParameter(
"mqtt_server", "MQTT 服务器地址",
_config.mqtt_server, 40);
_param_mqtt_port = new WiFiManagerParameter(
"mqtt_port", "MQTT 端口",
_config.mqtt_port, 6);
_param_mqtt_user = new WiFiManagerParameter(
"mqtt_user", "MQTT 用户名",
_config.mqtt_user, 20);
_param_mqtt_pass = new WiFiManagerParameter(
"mqtt_pass", "MQTT 密码",
_config.mqtt_pass, 20);
_param_device_id = new WiFiManagerParameter(
"device_id", "设备名称",
_config.device_id, 20);
_param_env_note = new WiFiManagerParameter(
"env_note", "🏠 环境备注 (如: 主卧/客厅/老人房)",
_config.env_note, 20);
// 注册自定义参数
_wm.addParameter(_param_mqtt_server);
_wm.addParameter(_param_mqtt_port);
_wm.addParameter(_param_mqtt_user);
_wm.addParameter(_param_mqtt_pass);
_wm.addParameter(_param_device_id);
_wm.addParameter(_param_env_note);
// 设置 AP 模式回调 (静态函数)
_wm.setAPCallback(_apCallback);
// 尝试连接 WiFi
_connected = _connectWiFi();
// 有配置 → 直接连
bool hasConfig = LittleFS.exists(CONFIG_FILE_DEVICE);
Serial.printf("[WiFi] 配置文件: %s\n", hasConfig ? "存在" : "不存在");
if (hasConfig) {
_connected = _connectWiFi();
Serial.printf("[WiFi] 自动连接: %s\n", _connected ? "成功" : "失败");
} else {
Serial.println(("[WiFi] 无配置, 跳过连接"));
_connected = false;
}
if (!_connected) {
// 如果自动连接失败, 启动配网页面
Serial.println(F("[WiFi] 自动连接失败, 启动配网模式..."));
_setLEDMode(1); // 快闪 = 配网模式
_connected = _connectWiFi(); // 内部会启动配网页面
}
if (_connected) {
_setLEDMode(2); // 常亮 = 已连接
_setLEDMode(2); // 快闪 = 配网模式
_startPortal(); // 只开 AP
} else {
_setLEDMode(3); // 常亮
}
_lastCheckTime = millis();
@@ -137,35 +103,82 @@ bool WiFiMgmt::begin() {
// ============================================================
bool WiFiMgmt::loop() {
// 检测按钮长按
// --- BLE 待处理数据 (从 loop 上下文安全处理, 不占 BTC_TASK 栈) ---
_processBLEPending();
// --- 配网完成 → WiFi 连接 (Portal 内/外 统一处理) ---
if (_portalStopFlag) {
if (_portalActive) {
_stopPortal();
}
_portalStopFlag = false;
if (_pendingSSID.length() > 0) {
Serial.printf("[WiFi] 配网连接: %s\n", _pendingSSID.c_str());
_connected = _tryConnect(_pendingSSID.c_str(), _pendingPass.c_str());
_pendingSSID = ""; _pendingPass = "";
if (_connected) {
_setLEDMode(3);
_provisionSuccess = true; // 标记配网成功 → main 里触发 MQTT 重连
} else {
_setLEDMode(2);
if (!_portalActive) {
_startPortal(); // 连接失败 → 开 AP 重试
}
}
}
return _connected;
}
// --- Portal 模式 ---
if (_portalActive) {
_dns->processNextRequest();
_server->handleClient();
// 配网超时 → 回连上一个 WiFi
if (WIFI_CONFIG_TIMEOUT > 0 && millis() - _portalStartTime > (uint32_t)WIFI_CONFIG_TIMEOUT * 1000) {
Serial.printf("[WiFi] ⏰ 配网超时 %ds, 回连已存 WiFi...\n", WIFI_CONFIG_TIMEOUT);
_stopPortal();
if (_config.wifi_ssid[0] != '\0') {
_connected = _connectWiFi();
} else {
_connected = false;
Serial.println("[WiFi] 无已存 WiFi");
}
if (_connected) {
_setLEDMode(3); // 常亮 = 在线
} else {
_setLEDMode(1); // 慢闪 = 离线, WiFi.AutoReconnect 后台重试
}
}
delay(10);
return _connected;
}
// --- 正常运行 ---
if (_checkButtonLongPress()) {
Serial.println(F("[WiFi] ⚠️ 检测到长按, 清除配置并重启..."));
resetSettings();
Serial.println(("[WiFi] 长按, 进配网模式"));
_setLEDMode(2);
WiFi.disconnect();
_startPortal();
_connected = false;
return false;
}
// 检查 WiFi 状态
if (millis() - _lastCheckTime > WIFI_RETRY_INTERVAL) {
_lastCheckTime = millis();
if (WiFi.status() != WL_CONNECTED) {
_connected = false;
_setLEDMode(0); // 慢闪 = 未连接
Serial.println(F("[WiFi] ⚠️ WiFi 断开, 尝试重连..."));
_connectWiFi();
} else {
wl_status_t s = WiFi.status();
if (s != WL_CONNECTED) {
if (_connected) {
_connected = false;
_setLEDMode(1);
Serial.printf("[WiFi] 断开! status=%d RSSI=%d\n", s, WiFi.RSSI());
}
// 不手动 reconnect(), 让 WiFi.setAutoReconnect(true) 自动处理
} else if (!_connected) {
_connected = true;
_setLEDMode(2); // 常亮
}
}
// LED 闪烁处理
if (!_connected) {
uint32_t interval = (_wm.getConfigPortalActive()) ? 300 : 1000;
if (millis() - _ledLastToggle > interval) {
_ledLastToggle = millis();
_ledState = !_ledState;
digitalWrite(LED_WIFI_PIN, _ledState ? HIGH : LOW);
_setLEDMode(3);
Serial.printf("[WiFi] 已连接 RSSI=%d\n", WiFi.RSSI());
}
}
@@ -177,128 +190,443 @@ bool WiFiMgmt::loop() {
// ============================================================
bool WiFiMgmt::_connectWiFi() {
// 检查是否有已保存的配置
// autoConnect 会先尝试连接, 失败后启动 AP 配网页面
_connected = _wm.autoConnect(WIFI_AP_SSID, WIFI_AP_PASSWORD);
if (_connected) {
Serial.println(F("[WiFi] ✅ 已连接!"));
Serial.printf(" WiFi名称: %s\n", WiFi.SSID().c_str());
Serial.printf(" IP地址: %s\n", WiFi.localIP().toString().c_str());
Serial.printf(" MAC地址: %s\n", WiFi.macAddress().c_str());
// 重新加载配置 (确保获取最新值)
// autoConnect 过程中如果有保存, 回调已写入 LittleFS
_loadConfig();
// 同步自定义参数 (如果配置是从 LittleFS 加载的)
_param_mqtt_server->setValue(_config.mqtt_server, sizeof(_config.mqtt_server));
_param_mqtt_port->setValue(_config.mqtt_port, sizeof(_config.mqtt_port));
_param_mqtt_user->setValue(_config.mqtt_user, sizeof(_config.mqtt_user));
_param_mqtt_pass->setValue(_config.mqtt_pass, sizeof(_config.mqtt_pass));
_param_device_id->setValue(_config.device_id, sizeof(_config.device_id));
_param_env_note->setValue(_config.env_note, sizeof(_config.env_note));
Serial.printf(" 设备名: %s\n", _config.device_id);
Serial.printf(" 环境: %s\n", _config.env_note);
Serial.printf(" MQTT服务器: %s:%s\n", _config.mqtt_server, _config.mqtt_port);
if (_config.wifi_ssid[0] == '\0') {
Serial.println(("[WiFi] 无已存WiFi密码"));
return false;
}
Serial.printf("[WiFi] 连接: %s\n", _config.wifi_ssid);
// 崩溃重启后 WiFi 模块可能残留旧状态, 彻底重置
WiFi.disconnect(true);
delay(200);
WiFi.mode(WIFI_STA);
delay(100);
WiFi.begin(_config.wifi_ssid, _config.wifi_pass);
int retry = 0;
while (WiFi.status() != WL_CONNECTED && retry < 40) { // 20s
delay(500); Serial.print("."); yield(); retry++;
}
Serial.println();
_connected = (WiFi.status() == WL_CONNECTED);
Serial.printf("[WiFi] 连接结果: %s status=%d\n", _connected ? "成功" : "失败", WiFi.status());
if (_connected) Serial.printf("[WiFi] SSID=%s IP=%s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
return _connected;
}
// ============================================================
// 配网页面
// ============================================================
void WiFiMgmt::startConfigPortal() {
Serial.println(F("[WiFi] 🔧 手动启动配网页面..."));
_setLEDMode(1);
_wm.startConfigPortal(WIFI_AP_SSID, WIFI_AP_PASSWORD);
_connected = (WiFi.status() == WL_CONNECTED);
if (_connected) {
_setLEDMode(2);
bool WiFiMgmt::_tryConnect(const char* ssid, const char* pass) {
// ★ 先从 AP 模式彻底断开再切 STA和 _connectWiFi() 保持一致
WiFi.disconnect(true);
delay(200);
WiFi.mode(WIFI_STA);
delay(100);
WiFi.persistent(true); // 确保持久化 WiFi 密码到 NVS (断电不丢)
WiFi.begin(ssid, pass);
int retry = 0;
while (WiFi.status() != WL_CONNECTED && retry < 40) { // 20s
delay(500); Serial.print("."); retry++;
}
bool ok = (WiFi.status() == WL_CONNECTED);
Serial.printf("\n[WiFi] 连接结果: %s status=%d\n", ok ? "成功!" : "失败", WiFi.status());
return ok;
}
// ============================================================
// 重置设置
// 配网 Portal (AP + WebServer)
// ============================================================
void WiFiMgmt::startConfigPortal() {
_setLEDMode(2);
_startPortal();
}
void WiFiMgmt::_startPortal() {
if (_portalActive) return;
WiFi.disconnect(true);
delay(200);
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(IPAddress(192,168,4,1), IPAddress(192,168,4,1), IPAddress(255,255,255,0));
// AP 名称 = 前缀 + MAC 后4位 (保证每台设备唯一, 方便区分多设备配网)
String mac = WiFi.macAddress(); // e.g. "8C:AA:B5:12:34:56"
_apSsid = String(WIFI_AP_SSID_PREFIX) + mac.substring(9, 11) + mac.substring(12, 14);
WiFi.softAP(_apSsid.c_str(), WIFI_AP_PASSWORD);
Serial.printf("[WiFi] AP: %s IP: 192.168.4.1\n", _apSsid.c_str());
// DNS 劫持: 所有域名 → 192.168.4.1 → 触发手机强制门户弹窗
_dns = new DNSServer();
_dns->start(53, "*", WiFi.softAPIP());
_server = new WebServer(80);
_server->on("/", HTTP_GET, [this]() { _handleRoot(); });
_server->on("/save", HTTP_POST, [this]() { _handleSave(); });
_server->on("/reset", HTTP_GET, [this]() { _handleReset(); });
_server->onNotFound( [this]() { _handleRoot(); });
_server->begin();
_portalActive = true;
_portalStartTime = millis();
Serial.println(("[WiFi] 手机连 AP 后浏览器打开 http://192.168.4.1"));
_startBLE(); // 同时启动 BLE 配网广播
}
void WiFiMgmt::_stopPortal() {
if (!_portalActive) return;
_stopBLE();
if (_server) { _server->stop(); delete _server; _server = nullptr; }
if (_dns) { _dns->stop(); delete _dns; _dns = nullptr; }
WiFi.softAPdisconnect(true);
_portalActive = false;
}
// ============================================================
// Web 处理器
// ============================================================
void WiFiMgmt::_handleRoot() {
_server->send(200, "text/html; charset=utf-8", _htmlPage());
}
void WiFiMgmt::_handleSave() {
if (_server->hasArg("s")) {
_pendingSSID = _server->arg("s");
_pendingPass = _server->arg("p");
// 持久化 WiFi 密码到 LittleFS
strncpy(_config.wifi_ssid, _pendingSSID.c_str(), sizeof(_config.wifi_ssid)-1);
strncpy(_config.wifi_pass, _pendingPass.c_str(), sizeof(_config.wifi_pass)-1);
if (_server->hasArg("device_id")) strncpy(_config.device_id, _server->arg("device_id").c_str(), sizeof(_config.device_id)-1);
if (_server->hasArg("env_type")) strncpy(_config.env_type, _server->arg("env_type").c_str(), sizeof(_config.env_type)-1);
if (_server->hasArg("env_note")) strncpy(_config.env_note, _server->arg("env_note").c_str(), sizeof(_config.env_note)-1);
if (_server->hasArg("mqtt_server")) strncpy(_config.mqtt_server, _server->arg("mqtt_server").c_str(), sizeof(_config.mqtt_server)-1);
if (_server->hasArg("mqtt_port")) strncpy(_config.mqtt_port, _server->arg("mqtt_port").c_str(), sizeof(_config.mqtt_port)-1);
if (_server->hasArg("mqtt_user")) strncpy(_config.mqtt_user, _server->arg("mqtt_user").c_str(), sizeof(_config.mqtt_user)-1);
if (_server->hasArg("mqtt_pass")) strncpy(_config.mqtt_pass, _server->arg("mqtt_pass").c_str(), sizeof(_config.mqtt_pass)-1);
_saveConfig();
_server->send(200, "text/html; charset=utf-8",
"<!DOCTYPE html><html lang='zh-CN'><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>配置完成</title><style>body{font-family:sans-serif;background:#f3faf8;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}"
".box{text-align:center;background:#fff;padding:32px;border-radius:16px;box-shadow:0 2px 12px rgba(0,0,0,0.08);max-width:340px}"
"h1{color:#12a889;font-size:20px}.ok{font-size:48px;margin:16px}"
"p{color:#8aa09a;font-size:14px;line-height:1.8}"
"</style></head><body><div class='box'><div class='ok' id='icon'>⏳</div>"
"<h1 id='title'>配置已保存</h1><p id='msg'>设备正在连接 WiFi...<br>等待指示灯<b>常亮</b></p>"
"</div><script>"
"var n=0;setInterval(function(){n++;"
"if(n>8){document.getElementById('icon').textContent='✅';"
"document.getElementById('msg').innerHTML='请切换回您的 WiFi 网络<br><b>指示灯常亮</b>即连接成功';clearInterval()}"
"},2000);</script></body></html>");
_portalStopFlag = true;
return;
}
_handleRoot();
}
void WiFiMgmt::_handleReset() {
_server->send(200, "text/html; charset=utf-8",
"<!DOCTYPE html><html lang='zh-CN'><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>已重置</title>"
"<style>body{font-family:sans-serif;background:#f3faf8;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}"
".box{text-align:center;background:#fff;padding:32px;border-radius:16px;box-shadow:0 2px 12px rgba(0,0,0,0.08)}"
"h1{color:#f53f3f;font-size:18px}p{color:#8aa09a;font-size:14px}</style></head><body><div class='box'><h1>配置已清除</h1><p>设备正在重启...</p></div></body></html>");
delay(500);
_stopPortal();
resetSettings();
}
// ============================================================
// 配网 HTML 页面 (精简版)
// ============================================================
void WiFiMgmt::_updateWifiScan() {
_wifiScanCache = "";
int n = WiFi.scanNetworks(false, true);
for (int i = 0; i < n; i++) {
_wifiScanCache += "<option value='" + WiFi.SSID(i) + "'>" + WiFi.SSID(i) + " (" + String(WiFi.RSSI(i)) + "dBm)</option>";
}
if (n == 0) _wifiScanCache = "<option value=''>未发现 WiFi</option>";
WiFi.scanDelete();
_lastScanTime = millis();
}
String WiFiMgmt::_htmlPage() {
if (_wifiScanCache.length() == 0) _updateWifiScan();
String html;
html.reserve(4096);
html += "<!DOCTYPE html><html lang='zh-CN'><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'>";
html += "<meta name='theme-color' content='#12a889'><title>设备配网</title><style>";
html += ":root{--p:#12a889;--bg:#f3faf8;--cb:#fff;--ch:#eef8f5;--cg:#e9fff8;--t:#15201d;--t2:#4e6660;--t3:#8aa09a}";
html += "*{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;background:var(--bg);min-height:100vh;padding:12px 16px 24px;color:var(--t);font-size:14px;line-height:1.5}";
html += ".wrap{max-width:420px;margin:0 auto}.hdr{display:flex;align-items:center;justify-content:center;position:relative;padding:20px 0 16px}";
html += ".hdricon{width:56px;height:56px;border-radius:50%;background:var(--cg);display:flex;align-items:center;justify-content:center;font-size:28px;margin-right:14px;box-shadow:0 4px 12px rgba(18,168,137,.12)}";
html += "h1{font-size:20px;font-weight:700;color:var(--t)}h3{font-size:12px;font-weight:400;color:var(--t2);margin-top:4px}";
html += ".card{background:var(--cb);border-radius:16px;padding:20px;margin-bottom:16px;box-shadow:0 2px 12px rgba(0,0,0,.06)}";
html += ".chdr{display:flex;align-items:center;gap:12px;margin-bottom:18px}.chicon{width:36px;height:36px;border-radius:10px;background:var(--cg);display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}";
html += ".ctitle{font-size:16px;font-weight:600;color:var(--t)}.csub{font-size:12px;color:var(--t3);margin-top:2px}";
html += ".field{margin-top:14px}.field:first-child{margin-top:0}label{display:block;font-size:14px;font-weight:500;color:var(--t2);margin-bottom:8px}";
html += ".iwrap{display:flex;align-items:center;background:var(--ch);border-radius:10px;border:1px solid transparent;transition:border-color .2s}";
html += ".iicon{padding:0 10px;font-size:16px;color:var(--t3)}input,select{flex:1;width:100%;padding:12px 14px;font-size:14px;border:none;background:transparent;color:var(--t);outline:none}";
html += ".iwrap:focus-within{border-color:var(--p);background:#fff}select{-webkit-appearance:none;padding-right:30px}";
html += ".gate{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-radius:12px;background:var(--cg);margin-bottom:14px;font-size:13px}";
html += ".glabel{color:var(--t2)}.gid{color:var(--p);font-weight:600}";
html += ".btns{display:flex;flex-direction:column;gap:12px;margin-top:8px}";
html += "button{width:100%;height:48px;border:none;border-radius:24px;font-size:15px;font-weight:600;color:#fff;background:var(--p);box-shadow:0 2px 6px rgba(18,168,137,.2);cursor:pointer}";
html += "button.danger{background:#fff;color:#f53f3f;border:1px solid #ffd6d6;box-shadow:none}";
html += ".ft{text-align:center;font-size:11px;color:var(--t3);padding:20px 0 6px}";
html += "@media(prefers-color-scheme:dark){body{background:#0f1f1c;color:#e6f0ee}.card{background:#182b27}.iwrap{background:#1f3530}.iwrap:focus-within{background:#243d38}}";
html += "</style></head><body><div class='wrap'>";
// Header + 提示
html += "<div class='hdr'><div class='hdricon'>📡</div><div><h1>观照-五劳无感 设备配网</h1><h3>WiFi 联网与参数配置</h3></div></div>";
html += "<div class='gate' style='justify-content:center'><span style='color:var(--p)'>💡 也可用 <b>ESP-Touch</b> App 一键配网</span></div>";
html += "<form method='POST' action='/save'>";
html += "<div class='card'><div class='chdr'><div class='chicon'>📶</div><div><div class='ctitle'>无线网络</div><div class='csub'>选择 WiFi 或使用 SmartConfig</div></div></div>";
html += "<div class='field'><label>WiFi 名称</label><div class='iwrap'><span class='iicon'>📡</span><select name='s'>";
html += _wifiScanCache;
html += "</select></div></div>";
html += "<div class='field'><label>WiFi 密码</label><div class='iwrap'><span class='iicon'>🔒</span><input type='password' name='p' placeholder='请输入 WiFi 密码'></div></div></div>";
html += "<div class='card'><div class='chdr'><div class='chicon'>⚙️</div><div><div class='ctitle'>设备基础信息</div><div class='csub'>唯一标识与安装位置</div></div></div>";
html += "<div class='field'><label>设备编号</label><div class='iwrap'><span class='iicon'>#</span><input type='text' name='device_id' value='";
html += _config.device_id;
html += "'></div></div>";
html += "<div class='field'><label>环境类型</label><div class='iwrap'><span class='iicon'>🏷️</span><select name='env_type'>";
const char* envTypes[] = {"bedroom","livingroom","bathroom","hallway","balcony","kitchen"};
const char* envLabels[] = {"🛏️ 卧室","🛋️ 客厅","🛁 浴室/卫生间","🚶 走廊","🌿 阳台","🍳 厨房"};
for (int i = 0; i < 6; i++) {
html += "<option value='";
html += envTypes[i];
html += "'";
if (strcmp(_config.env_type, envTypes[i]) == 0 || (strlen(_config.env_type) == 0 && i == 0))
html += " selected";
html += ">";
html += envLabels[i];
html += "</option>";
}
html += "</select></div></div>";
html += "<div class='field'><label>位置备注</label><div class='iwrap'><span class='iicon'>🏠</span><input type='text' name='env_note' placeholder='如: 主卧 / 客厅 / 老人房' value='";
html += _config.env_note;
html += "'></div></div></div>";
html += "<input type='hidden' name='mqtt_server' value='";
html += _config.mqtt_server;
html += "'><input type='hidden' name='mqtt_port' value='";
html += _config.mqtt_port;
html += "'><input type='hidden' name='mqtt_user' value='";
html += _config.mqtt_user;
html += "'><input type='hidden' name='mqtt_pass' value='";
html += _config.mqtt_pass;
html += "'>";
html += "<div class='btns'><button type='submit'>保存配置并重启连接</button></div></form>";
html += "<button class='danger' onclick=\"if(confirm('确定恢复出厂?'))location.href='/reset'\">恢复出厂配置</button>";
html += "<div class='ft'>Health Monitor · Device Provisioning</div></div></body></html>";
return html;
}
// ============================================================
// BLE 蓝牙配网
// ============================================================
void WiFiMgmt::_startBLE() {
// 如果 BLE 栈已在运行, 仅重启广播 (避免 deinit→init 失败)
if (_bleServer != nullptr) {
if (_bleAdv) {
// 重设广告数据 (stop 后 start 需要重新配置)
BLEAdvertisementData advData;
std::string rawName;
rawName += (char)(_apSsid.length() + 1);
rawName += (char)0x09;
rawName += _apSsid.c_str();
advData.addData(rawName);
_bleAdv->setAdvertisementData(advData);
_bleAdv->setScanResponse(false);
_bleAdv->start();
}
Serial.println("[BLE] ✅ 广播已重开");
return;
}
BLEDevice::init(_apSsid.c_str()); // BLE 名称跟随 AP 名称, 完整名 "HealthMonitor-XXXX"
_bleServer = BLEDevice::createServer();
BLEService* svc = _bleServer->createService("4fafc201-1fb5-459e-8fcc-c5c9c331914b");
// 特征1: 设备信息 (READ) — 小程序连接后读取设备编号等
_bleInfoChar = svc->createCharacteristic(
"4fafc202-1fb5-459e-8fcc-c5c9c331914b",
BLECharacteristic::PROPERTY_READ
);
// 设备信息 JSON (最多 512 字节)
JsonDocument info;
info["device_id"] = _config.device_id;
info["mqtt_server"] = _config.mqtt_server;
info["mqtt_port"] = _config.mqtt_port;
info["fw_version"] = FW_VERSION;
String infoJson;
serializeJson(info, infoJson);
_bleInfoChar->setValue(infoJson.c_str());
// 特征2: 配网写入 (WRITE + NOTIFY) — 小程序发配网数据, 设备回复
_bleChar = svc->createCharacteristic(
"4fafc203-1fb5-459e-8fcc-c5c9c331914b",
BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY
);
_bleChar->setCallbacks(new BLEProvisionCB());
_bleChar->addDescriptor(new BLE2902()); // 启用 notify
_bleChar->setValue(""); // 初始为空
svc->start();
_bleAdv = _bleServer->getAdvertising();
BLEAdvertisementData advData;
// 完整名通过 addData 嵌入广播包 (AD type 0x09)
std::string rawName;
rawName += (char)(_apSsid.length() + 1);
rawName += (char)0x09;
rawName += _apSsid.c_str();
advData.addData(rawName);
_bleAdv->setAdvertisementData(advData);
_bleAdv->setScanResponse(false); // 不需要 scan response, 全在主包
_bleAdv->start();
Serial.printf("[BLE] ✅ 配网广播: %s device_id=%s\n", _apSsid.c_str(), _config.device_id);
}
void WiFiMgmt::_stopBLE() {
if (!_bleServer) return;
// ★ 核心修复: 先断开所有 BLE 客户端连接,
// 否则手机端缓存连接状态, 下次扫描不到设备
size_t connCount = _bleServer->getConnectedCount();
if (connCount > 0) {
Serial.printf("[BLE] 断开 %d 个已连接客户端\n", connCount);
for (size_t i = 0; i < connCount; i++) {
_bleServer->disconnect(i);
}
delay(300); // 等待 BLE 协议栈完成断开流程
}
if (_bleAdv) { _bleAdv->stop(); }
Serial.println("[BLE] 广播已暂停");
}
// BLE 线程安全入队 (仅暂存, 不在 BTC_TASK 回调里处理)
void WiFiMgmt::enqueueBLEJson(const char* json) {
_blePendingJson = json;
_blePendingFlag = true;
}
// 在 loop() 上下文中处理 BLE 配网数据 (安全 — 不在 BTC_TASK 栈中)
void WiFiMgmt::_processBLEPending() {
if (!_blePendingFlag) return;
_blePendingFlag = false;
Serial.printf("[BLE] loop 处理配网: %s\n", _blePendingJson.c_str());
handleBLEProvision(_blePendingJson.c_str());
_blePendingJson = "";
}
void WiFiMgmt::handleBLEProvision(const char* json) {
JsonDocument doc;
DeserializationError err = deserializeJson(doc, json);
if (err) {
Serial.printf("[BLE] JSON 解析失败: %s\n", err.c_str());
if (_bleChar) { _bleChar->setValue("ERR_JSON"); _bleChar->notify(); }
return;
}
// 提取 WiFi 凭证
const char* ssid = doc["s"] | "";
const char* pass = doc["p"] | "";
if (strlen(ssid) == 0) {
Serial.println("[BLE] 未提供 WiFi SSID");
if (_bleChar) { _bleChar->setValue("ERR_NO_SSID"); _bleChar->notify(); }
return;
}
_pendingSSID = ssid;
_pendingPass = pass;
// 保存到 LittleFS (和 Web 配网完全相同的逻辑)
strncpy(_config.wifi_ssid, ssid, sizeof(_config.wifi_ssid) - 1);
strncpy(_config.wifi_pass, pass, sizeof(_config.wifi_pass) - 1);
if (doc.containsKey("device_id")) strncpy(_config.device_id, doc["device_id"] | "", sizeof(_config.device_id) - 1);
if (doc.containsKey("env_note")) strncpy(_config.env_note, doc["env_note"] | "", sizeof(_config.env_note) - 1);
if (doc.containsKey("env_type")) strncpy(_config.env_type, doc["env_type"] | "", sizeof(_config.env_type) - 1);
if (doc.containsKey("mqtt_server")) strncpy(_config.mqtt_server, doc["mqtt_server"] | "", sizeof(_config.mqtt_server) - 1);
if (doc.containsKey("mqtt_port")) strncpy(_config.mqtt_port, doc["mqtt_port"] | "", sizeof(_config.mqtt_port) - 1);
if (doc.containsKey("mqtt_user")) strncpy(_config.mqtt_user, doc["mqtt_user"] | "", sizeof(_config.mqtt_user) - 1);
if (doc.containsKey("mqtt_pass")) strncpy(_config.mqtt_pass, doc["mqtt_pass"] | "", sizeof(_config.mqtt_pass) - 1);
_saveConfig();
_portalStopFlag = true; // 触发延迟关闭 AP+BLE → 连接 WiFi
Serial.printf("[BLE] ✅ 配网完成, SSID=%s\n", ssid);
// 回复 OK
if (_bleChar) { _bleChar->setValue("OK"); _bleChar->notify(); }
delay(100);
}
// ============================================================
// 重置
// ============================================================
void WiFiMgmt::resetSettings() {
Serial.println(F("[WiFi] 清除所有配置..."));
// 删除 LittleFS 中的配置文件
if (LittleFS.exists(CONFIG_FILE_WIFI)) LittleFS.remove(CONFIG_FILE_WIFI);
if (LittleFS.exists(CONFIG_FILE_MQTT)) LittleFS.remove(CONFIG_FILE_MQTT);
Serial.println(("[WiFi] 清除所有配置..."));
if (LittleFS.exists(CONFIG_FILE_WIFI)) LittleFS.remove(CONFIG_FILE_WIFI);
if (LittleFS.exists(CONFIG_FILE_MQTT)) LittleFS.remove(CONFIG_FILE_MQTT);
if (LittleFS.exists(CONFIG_FILE_DEVICE)) LittleFS.remove(CONFIG_FILE_DEVICE);
// 清除 WiFiManager 的配置
_wm.resetSettings();
// LED 快闪提示
for (int i = 0; i < 10; i++) {
digitalWrite(LED_WIFI_PIN, !digitalRead(LED_WIFI_PIN));
delay(100);
}
Serial.println(F("[WiFi] 🔄 正在重启..."));
WiFi.disconnect(true, true);
for (int i = 0; i < 10; i++) { digitalWrite(LED_WIFI_PIN, !digitalRead(LED_WIFI_PIN)); delay(100); }
Serial.println(("[WiFi] 正在重启..."));
delay(500);
ESP.restart();
}
// ============================================================
// 按钮长按检测
// 按钮
// ============================================================
bool WiFiMgmt::_checkButtonLongPress() {
bool pressed = (digitalRead(CONFIG_BUTTON_PIN) == LOW);
if (pressed && !_buttonPressed) {
// 按下
_buttonPressed = true;
_buttonPressTime = millis();
_buttonPressed = true; _buttonPressTime = millis();
} else if (!pressed && _buttonPressed) {
// 松开
_buttonPressed = false;
if (millis() - _buttonPressTime >= BUTTON_LONG_PRESS_MS) {
return true; // 长按确认
}
if (millis() - _buttonPressTime >= BUTTON_LONG_PRESS_MS) return true;
}
return false;
}
// ============================================================
// LittleFS 配置持久化
// LittleFS
// ============================================================
bool WiFiMgmt::_loadConfig() {
if (!LittleFS.exists(CONFIG_FILE_DEVICE)) {
return false;
}
if (!LittleFS.exists(CONFIG_FILE_DEVICE)) return false;
File file = LittleFS.open(CONFIG_FILE_DEVICE, "r");
if (!file) return false;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, file);
file.close();
if (err) {
Serial.printf("[WiFi] JSON 解析失败: %s\n", err.c_str());
return false;
}
strncpy(_config.mqtt_server, doc["mqtt_server"] | MQTT_DEFAULT_SERVER, sizeof(_config.mqtt_server) - 1);
strncpy(_config.mqtt_port, doc["mqtt_port"] | MQTT_DEFAULT_PORT_STR, sizeof(_config.mqtt_port) - 1);
strncpy(_config.mqtt_user, doc["mqtt_user"] | MQTT_DEFAULT_USER, sizeof(_config.mqtt_user) - 1);
strncpy(_config.mqtt_pass, doc["mqtt_pass"] | MQTT_DEFAULT_PASS, sizeof(_config.mqtt_pass) - 1);
strncpy(_config.device_id, doc["device_id"] | MQTT_DEFAULT_DEVICE, sizeof(_config.device_id) - 1);
strncpy(_config.env_note, doc["env_note"] | MQTT_DEFAULT_ENV, sizeof(_config.env_note) - 1);
Serial.println(F("[WiFi] ✅ 设备配置已加载"));
Serial.printf(" 设备名: %s, 环境: %s\n", _config.device_id, _config.env_note);
if (err) return false;
strncpy(_config.mqtt_server, doc["mqtt_server"] | MQTT_DEFAULT_SERVER, sizeof(_config.mqtt_server)-1);
strncpy(_config.mqtt_port, doc["mqtt_port"] | MQTT_DEFAULT_PORT_STR, sizeof(_config.mqtt_port)-1);
strncpy(_config.mqtt_user, doc["mqtt_user"] | MQTT_DEFAULT_USER, sizeof(_config.mqtt_user)-1);
strncpy(_config.mqtt_pass, doc["mqtt_pass"] | MQTT_DEFAULT_PASS, sizeof(_config.mqtt_pass)-1);
strncpy(_config.device_id, doc["device_id"] | MQTT_DEFAULT_DEVICE, sizeof(_config.device_id)-1);
strncpy(_config.env_note, doc["env_note"] | MQTT_DEFAULT_ENV, sizeof(_config.env_note)-1);
strncpy(_config.env_type, doc["env_type"] | "bedroom", sizeof(_config.env_type)-1);
strncpy(_config.wifi_ssid, doc["wifi_ssid"] | "", sizeof(_config.wifi_ssid)-1);
strncpy(_config.wifi_pass, doc["wifi_pass"] | "", sizeof(_config.wifi_pass)-1);
Serial.printf("[WiFi] 配置加载, 已存WiFi: %s\n", _config.wifi_ssid[0] ? _config.wifi_ssid : "(空)");
return true;
}
@@ -310,103 +638,45 @@ bool WiFiMgmt::_saveConfig() {
doc["mqtt_pass"] = _config.mqtt_pass;
doc["device_id"] = _config.device_id;
doc["env_note"] = _config.env_note;
doc["env_type"] = _config.env_type;
doc["wifi_ssid"] = _config.wifi_ssid;
doc["wifi_pass"] = _config.wifi_pass;
File file = LittleFS.open(CONFIG_FILE_DEVICE, "w");
if (!file) {
Serial.println(F("[WiFi] ❌ 无法创建配置文件"));
return false;
}
if (serializeJson(doc, file) == 0) {
Serial.println(F("[WiFi] ❌ 配置写入失败"));
file.close();
return false;
}
if (!file) return false;
if (serializeJson(doc, file) == 0) { file.close(); return false; }
file.close();
Serial.println(F("[WiFi] ✅ 设备配置已保存"));
return true;
}
// ============================================================
// WiFiManager 保存参数回调
// Getter
// ============================================================
// ============================================================
// 静态回调函数 (WiFiManager 要求 C 函数指针)
// ============================================================
void WiFiMgmt::_apCallback(WiFiManager* wm) {
Serial.println(F("[WiFi] 🔧 进入配网模式"));
Serial.printf(" AP WiFi名称: %s\n", WIFI_AP_SSID);
Serial.printf(" AP 密码: %s\n", WIFI_AP_PASSWORD);
Serial.println(F(" 手机连接此 WiFi 即可打开配网页面 (自动弹出)"));
}
void WiFiMgmt::_saveParamsCallback() {
if (!_instance) return;
Serial.println(F("[WiFi] 💾 参数已保存"));
// 从 WiFiManager 参数中读取值
strncpy(_instance->_config.mqtt_server, _instance->_param_mqtt_server->getValue(),
sizeof(_instance->_config.mqtt_server) - 1);
strncpy(_instance->_config.mqtt_port, _instance->_param_mqtt_port->getValue(),
sizeof(_instance->_config.mqtt_port) - 1);
strncpy(_instance->_config.mqtt_user, _instance->_param_mqtt_user->getValue(),
sizeof(_instance->_config.mqtt_user) - 1);
strncpy(_instance->_config.mqtt_pass, _instance->_param_mqtt_pass->getValue(),
sizeof(_instance->_config.mqtt_pass) - 1);
strncpy(_instance->_config.device_id, _instance->_param_device_id->getValue(),
sizeof(_instance->_config.device_id) - 1);
strncpy(_instance->_config.env_note, _instance->_param_env_note->getValue(),
sizeof(_instance->_config.env_note) - 1);
_instance->_saveConfig();
Serial.println(F("[WiFi] 📋 新配置:"));
Serial.printf(" MQTT: %s:%s\n",
_instance->_config.mqtt_server,
_instance->_config.mqtt_port);
Serial.printf(" 设备: %s\n", _instance->_config.device_id);
Serial.printf(" 环境: %s\n", _instance->_config.env_note);
}
bool WiFiMgmt::isConnected() const { return _connected && (WiFi.status() == WL_CONNECTED); }
bool WiFiMgmt::justProvisioned() { bool v = _provisionSuccess; _provisionSuccess = false; return v; }
DeviceConfig WiFiMgmt::getConfig() const { return _config; }
const char* WiFiMgmt::getDeviceId() const { return _config.device_id; }
const char* WiFiMgmt::getEnvNote() const { return _config.env_note; }
// ============================================================
// Getter 方法
// LED
// ============================================================
bool WiFiMgmt::isConnected() const {
return _connected && (WiFi.status() == WL_CONNECTED);
}
void WiFiMgmt::_setLEDMode(uint8_t mode) { _ledMode = mode; }
DeviceConfig WiFiMgmt::getConfig() const {
return _config;
}
const char* WiFiMgmt::getDeviceId() const {
return _config.device_id;
}
const char* WiFiMgmt::getEnvNote() const {
return _config.env_note;
}
// ============================================================
// LED 模式控制
// ============================================================
void WiFiMgmt::_setLEDMode(uint8_t mode) {
// mode 由 loop() 中的闪烁逻辑处理
// 0=慢闪 1=快闪 2=常亮
switch (mode) {
case 0:
case 1:
// 闪烁由 loop 处理
break;
case 2:
digitalWrite(LED_WIFI_PIN, HIGH);
_ledState = true;
break;
void WiFiMgmt::_ledTaskFunc(void* param) {
const TickType_t fastMs = pdMS_TO_TICKS(300);
const TickType_t slowMs = pdMS_TO_TICKS(1000);
while (true) {
if (!_instance) { vTaskDelay(slowMs); continue; }
uint8_t mode = _instance->_ledMode;
switch (mode) {
case 0: digitalWrite(LED_WIFI_PIN, LOW); vTaskDelay(slowMs); break;
case 1: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(slowMs);
digitalWrite(LED_WIFI_PIN, LOW); vTaskDelay(slowMs); break;
case 2: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(fastMs);
digitalWrite(LED_WIFI_PIN, LOW); vTaskDelay(fastMs); break;
case 3: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(slowMs); break;
}
}
}