2420 lines
107 KiB
Plaintext
2420 lines
107 KiB
Plaintext
warning: in the working copy of 'include/config.h', LF will be replaced by CRLF the next time Git touches it
|
||
warning: in the working copy of 'include/mqtt_manager.h', LF will be replaced by CRLF the next time Git touches it
|
||
warning: in the working copy of 'include/radar_protocol.h', LF will be replaced by CRLF the next time Git touches it
|
||
warning: in the working copy of 'include/wifi_manager.h', LF will be replaced by CRLF the next time Git touches it
|
||
warning: in the working copy of 'src/wifi_manager.cpp', LF will be replaced by CRLF the next time Git touches it
|
||
diff --git a/include/config.h b/include/config.h
|
||
index a7207c1..6d2b281 100644
|
||
--- a/include/config.h
|
||
+++ b/include/config.h
|
||
@@ -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 // ABD1 (心率/呼吸/睡眠)
|
||
+
|
||
+// --- 毫米波雷达 ---
|
||
#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 ---
|
||
@@ -72,8 +77,8 @@
|
||
// ============================================================
|
||
|
||
#define WIFI_AP_SSID "HealthMonitor-Setup"
|
||
-#define WIFI_AP_PASSWORD "12345678"
|
||
-#define WIFI_CONFIG_TIMEOUT 300
|
||
+#define WIFI_AP_PASSWORD "1234567890"
|
||
+#define WIFI_CONFIG_TIMEOUT 0 // 0=不超时, 一直等待配置
|
||
#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; // 心率呼吸有效性
|
||
|
||
diff --git a/include/mqtt_manager.h b/include/mqtt_manager.h
|
||
index e830043..fb51036 100644
|
||
--- a/include/mqtt_manager.h
|
||
+++ b/include/mqtt_manager.h
|
||
@@ -83,6 +83,9 @@ public:
|
||
/** 本地报警上报 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)
|
||
// ============================================================
|
||
diff --git a/include/radar_manager.h b/include/radar_manager.h
|
||
index 30935fd..adbdb08 100644
|
||
--- a/include/radar_manager.h
|
||
+++ b/include/radar_manager.h
|
||
@@ -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
|
||
diff --git a/include/radar_protocol.h b/include/radar_protocol.h
|
||
index 205fc8e..302d625 100644
|
||
--- a/include/radar_protocol.h
|
||
+++ b/include/radar_protocol.h
|
||
@@ -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, // 离床/无人/无效
|
||
};
|
||
|
||
// ============================================================
|
||
diff --git a/include/wifi_manager.h b/include/wifi_manager.h
|
||
index 3e3a904..b6c327e 100644
|
||
--- a/include/wifi_manager.h
|
||
+++ b/include/wifi_manager.h
|
||
@@ -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,100 +47,66 @@ 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 resetSettings();
|
||
|
||
private:
|
||
- /**
|
||
- * @brief 尝试连接 WiFi
|
||
- */
|
||
- bool _connectWiFi();
|
||
-
|
||
- /**
|
||
- * @brief 设置 LED 模式 (线程安全, 无阻塞)
|
||
- * @param mode 0=灭 1=慢闪(断连1s) 2=快闪(配网300ms) 3=常亮(已连接)
|
||
- */
|
||
+ bool _connectWiFi(); // 仅尝试已保存凭据
|
||
+ bool _tryConnect(const char* ssid, const char* pass); // 尝试连接指定 WiFi
|
||
+ bool _loadConfig();
|
||
+ bool _saveConfig();
|
||
void _setLEDMode(uint8_t mode);
|
||
-
|
||
- /**
|
||
- * @brief LED 闪烁独立任务 (FreeRTOS, 不受主循环阻塞影响)
|
||
- */
|
||
static void _ledTaskFunc(void* param);
|
||
-
|
||
- /**
|
||
- * @brief 检测按钮长按
|
||
- */
|
||
bool _checkButtonLongPress();
|
||
|
||
- /**
|
||
- * @brief 加载/保存配置到 LittleFS
|
||
- */
|
||
- bool _loadConfig();
|
||
- bool _saveConfig();
|
||
-
|
||
- /**
|
||
- * @brief 保存 WiFi 参数回调 (WiFiManager 的回调)
|
||
- */
|
||
- static void _saveParamsCallback();
|
||
- static void _apCallback(WiFiManager* wm);
|
||
-
|
||
- WiFiManager _wm;
|
||
- DeviceConfig _config;
|
||
- bool _connected;
|
||
- uint32_t _lastCheckTime;
|
||
- uint32_t _buttonPressTime;
|
||
- bool _buttonPressed;
|
||
-
|
||
- // LED 独立任务 (不被 startConfigPortal 阻塞影响)
|
||
+ // Web 服务器
|
||
+ void _startPortal();
|
||
+ void _stopPortal();
|
||
+ void _handleRoot();
|
||
+ void _handleSave();
|
||
+ void _handleReset();
|
||
+ void _updateWifiScan(); // WiFi 扫描缓存
|
||
+ String _htmlPage(); // 返回配网 HTML 页面
|
||
+
|
||
+ // BLE 蓝牙配网
|
||
+ void _startBLE();
|
||
+ void _stopBLE();
|
||
+
|
||
+ 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;
|
||
+
|
||
+ // WiFi 扫描缓存 (避免每次打开页面都扫描3-8秒)
|
||
+ String _wifiScanCache;
|
||
+ uint32_t _lastScanTime;
|
||
+
|
||
+ // LED 独立任务
|
||
TaskHandle_t _ledTaskHandle;
|
||
volatile uint8_t _ledMode; // 0=灭 1=慢闪 2=快闪 3=常亮
|
||
|
||
- // 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;
|
||
+ // BLE
|
||
+ BLEServer* _bleServer;
|
||
+ BLECharacteristic* _bleInfoChar; // READ: 设备信息
|
||
+ BLECharacteristic* _bleChar; // WRITE+NOTIFY: 配网
|
||
+ BLEAdvertising* _bleAdv;
|
||
};
|
||
|
||
#endif // WIFI_MANAGER_H
|
||
diff --git a/include/wm_strings_zh.h b/include/wm_strings_zh.h
|
||
deleted file mode 100644
|
||
index e3398b2..0000000
|
||
--- a/include/wm_strings_zh.h
|
||
+++ /dev/null
|
||
@@ -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
|
||
diff --git a/platformio.ini b/platformio.ini
|
||
index 1c12f78..250f8dd 100644
|
||
--- a/platformio.ini
|
||
+++ b/platformio.ini
|
||
@@ -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 = COM16
|
||
+upload_port = COM18
|
||
diff --git a/src/alarm_manager.cpp b/src/alarm_manager.cpp
|
||
index 7bae9d0..fbeb265 100644
|
||
--- a/src/alarm_manager.cpp
|
||
+++ b/src/alarm_manager.cpp
|
||
@@ -64,9 +64,9 @@ 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);
|
||
@@ -148,29 +148,29 @@ 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 = 60; offMs = 60; pauseMs = 0; // 跌倒: 连速哔
|
||
+ beeps = 0; onMs = 200; offMs = 200; pauseMs = 0; // 跌倒: 急促哔哔
|
||
break;
|
||
case AlarmManager::ALARM_HEART_ABNORMAL:
|
||
case AlarmManager::ALARM_BREATH_ABNORMAL:
|
||
- beeps = 5; onMs = 80; offMs = 80; pauseMs = 400; // 心率/呼吸: 5连急哔
|
||
+ beeps = 5; onMs = 200; offMs = 150; pauseMs = 500; // 心率/呼吸: 5连哔
|
||
break;
|
||
- // 环境安全 — 次急
|
||
+ // 环境安全
|
||
case AlarmManager::ALARM_WATER_LEAK:
|
||
- beeps = 3; onMs = 120; offMs = 100; pauseMs = 500; // 水浸: 3连哔
|
||
+ beeps = 3; onMs = 250; offMs = 200; pauseMs = 600; // 水浸: 3连哔
|
||
break;
|
||
- // 环境舒适 — 温和
|
||
+ // 环境舒适
|
||
case AlarmManager::ALARM_TEMP_ABNORMAL:
|
||
case AlarmManager::ALARM_HUMI_ABNORMAL:
|
||
- beeps = 2; onMs = 200; offMs = 400; pauseMs = 1200; // 温湿度: 2声长停
|
||
+ beeps = 2; onMs = 300; offMs = 500; pauseMs = 1500; // 温湿度: 2声长停
|
||
break;
|
||
- // 设备状态 — 仅提示
|
||
+ // 设备状态
|
||
case AlarmManager::ALARM_SENSOR_FAULT:
|
||
- beeps = 1; onMs = 300; offMs = 3000; pauseMs = 0; // 传感器: 单声提醒
|
||
+ beeps = 1; onMs = 500; offMs = 3000; pauseMs = 0; // 传感器: 单声提醒
|
||
break;
|
||
default:
|
||
- beeps = 0; onMs = 500; offMs = 500; pauseMs = 0; // 持续
|
||
+ beeps = 0; onMs = 500; offMs = 500; pauseMs = 0; // 持续
|
||
break;
|
||
}
|
||
}
|
||
@@ -277,8 +277,13 @@ void AlarmManager::executeRestore(const AlarmCommand& cmd) {
|
||
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);
|
||
|
||
@@ -308,7 +313,7 @@ void AlarmManager::_setBuzzer(bool on) {
|
||
if (on) {
|
||
_buzzerOffTime = 0;
|
||
} else {
|
||
- ledcWrite(BUZZER_PWM_CHANNEL, 0);
|
||
+ digitalWrite(BUZZER_PIN, LOW);
|
||
_buzzerOffTime = 0;
|
||
}
|
||
}
|
||
@@ -328,16 +333,20 @@ void AlarmManager::_updateBuzzer() {
|
||
// 定时关闭
|
||
if (_buzzerOffTime > 0 && millis() >= _buzzerOffTime) {
|
||
_buzzerOn = false;
|
||
- ledcWrite(BUZZER_PWM_CHANNEL, 0);
|
||
+ digitalWrite(BUZZER_PIN, LOW);
|
||
Serial.println(F("[告警] ⏰ 蜂鸣器定时关闭"));
|
||
return;
|
||
}
|
||
|
||
uint32_t now = millis();
|
||
|
||
- // 云端定时: 持续响
|
||
+ // 云端定时: 哔哔声 (500ms响/500ms停), 不是长鸣
|
||
if (_buzzerOffTime > 0) {
|
||
- ledcWrite(BUZZER_PWM_CHANNEL, 128);
|
||
+ if (now - _buzzerToggleTime >= 500) {
|
||
+ _buzzerToggleTime = now;
|
||
+ _buzzerBeepState = !_buzzerBeepState;
|
||
+ digitalWrite(BUZZER_PIN, _buzzerBeepState ? HIGH : LOW);
|
||
+ }
|
||
return;
|
||
}
|
||
|
||
@@ -347,7 +356,7 @@ void AlarmManager::_updateBuzzer() {
|
||
if (now - _buzzerToggleTime >= (_buzzerBeepState ? _beepOnMs : _beepOffMs)) {
|
||
_buzzerToggleTime = now;
|
||
_buzzerBeepState = !_buzzerBeepState;
|
||
- ledcWrite(BUZZER_PWM_CHANNEL, _buzzerBeepState ? 128 : 0);
|
||
+ digitalWrite(BUZZER_PIN, _buzzerBeepState ? HIGH : LOW);
|
||
}
|
||
} else {
|
||
// 多声一组: N声哔 + 长停顿
|
||
@@ -358,7 +367,7 @@ void AlarmManager::_updateBuzzer() {
|
||
_buzzerToggleTime = now;
|
||
if (_beepCount >= _beepsPerCycle) _beepCount = 0;
|
||
_buzzerBeepState = true;
|
||
- ledcWrite(BUZZER_PWM_CHANNEL, 128);
|
||
+ digitalWrite(BUZZER_PIN, HIGH);
|
||
_beepCount++;
|
||
}
|
||
} else {
|
||
@@ -366,7 +375,7 @@ void AlarmManager::_updateBuzzer() {
|
||
if (now - _buzzerToggleTime >= _beepOnMs) {
|
||
_buzzerToggleTime = now;
|
||
_buzzerBeepState = false;
|
||
- ledcWrite(BUZZER_PWM_CHANNEL, 0);
|
||
+ digitalWrite(BUZZER_PIN, LOW);
|
||
}
|
||
}
|
||
}
|
||
diff --git a/src/button_manager.cpp b/src/button_manager.cpp
|
||
index 8b9fdce..bda20e7 100644
|
||
--- a/src/button_manager.cpp
|
||
+++ b/src/button_manager.cpp
|
||
@@ -44,13 +44,13 @@ void ButtonManager::begin() {
|
||
pinMode(BTN_ALARM_DISMISS_PIN, INPUT_PULLUP);
|
||
pinMode(BTN_PAGE_SWITCH_PIN, INPUT_PULLUP);
|
||
|
||
- // 读取初始状态, 避免启动时的虚假边沿
|
||
- _btnProvision.rawState = _readPin(BTN_PROVISION_PIN);
|
||
- _btnProvision.stableState = _btnProvision.rawState;
|
||
- _btnDismiss.rawState = _readPin(BTN_ALARM_DISMISS_PIN);
|
||
- _btnDismiss.stableState = _btnDismiss.rawState;
|
||
- _btnPage.rawState = _readPin(BTN_PAGE_SWITCH_PIN);
|
||
- _btnPage.stableState = _btnPage.rawState;
|
||
+ // 强制初始化为未按下 (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",
|
||
@@ -64,6 +64,9 @@ void ButtonManager::begin() {
|
||
// ============================================================
|
||
|
||
void ButtonManager::loop() {
|
||
+ // 开机 10 秒静默 (PCB 上电 GPIO 不稳 + 强制初始未按下)
|
||
+ if (millis() < 10000) return;
|
||
+
|
||
// 按钮1: 配网 (仅长按)
|
||
_processButton(_btnProvision, false, true);
|
||
|
||
diff --git a/src/main.cpp b/src/main.cpp
|
||
index d85963b..42d9d3e 100644
|
||
--- a/src/main.cpp
|
||
+++ b/src/main.cpp
|
||
@@ -49,7 +49,14 @@ bool g_localDhtFaultActive = false;
|
||
uint8_t g_tempAbnCnt = 0;
|
||
uint8_t g_humiAbnCnt = 0;
|
||
uint8_t g_radarTimeoutCnt = 0;
|
||
-#define ALARM_HYSTERESIS 3 // 连续3次确认才触发
|
||
+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秒后才清除 (防误消)
|
||
|
||
// ============================================================
|
||
// 时间同步
|
||
@@ -84,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);
|
||
@@ -201,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();
|
||
@@ -219,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;
|
||
|
||
@@ -250,54 +275,105 @@ void onLocalAlarmChange(uint8_t type, bool triggered, const char* desc) {
|
||
// ============================================================
|
||
|
||
void checkLocalAlarms(const RadarRawData& radar) {
|
||
+ // 环境类型 → 报警开关
|
||
+ 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);
|
||
+
|
||
// ─── 前置条件: 有人才检测人体生命体征 ───
|
||
bool humanPresent = radar.presence && radar.radarValid;
|
||
|
||
- // ─── ★★★★★ 人身安全: 跌倒 (立即触发, 无防抖) ───
|
||
- if (radar.fall && humanPresent && !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);
|
||
+ // ─── ★★★★★ 人身安全: 跌倒 (仅浴室/走廊/客厅) ───
|
||
+ 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);
|
||
+ }
|
||
+ }
|
||
}
|
||
|
||
- // ─── ★★★★★ 人身安全: 心率异常 (有人才检测) ───
|
||
- if (humanPresent && radar.vitalValid && radar.heartRate > 0) {
|
||
+ // ─── ★★★★★ 人身安全: 心率异常 (仅卧室+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 && !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 if (!heartAbn && g_localHeartAbnActive) {
|
||
- g_localHeartAbnActive = false;
|
||
- g_alarm.clearLocal(AlarmManager::ALARM_HEART_ABNORMAL);
|
||
+ 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
|
||
|
||
- // ─── ★★★★★ 人身安全: 呼吸异常 (有人才检测) ───
|
||
- if (humanPresent && radar.vitalValid && radar.breathRate > 0) {
|
||
+ // ─── ★★★★★ 人身安全: 呼吸异常 (仅卧室+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 && !g_localBreathAbnActive) {
|
||
- g_localBreathAbnActive = true;
|
||
- char buf[32];
|
||
- snprintf(buf, sizeof(buf), "呼吸异常: %d 次/分", radar.breathRate);
|
||
- g_alarm.triggerLocal(AlarmManager::ALARM_BREATH_ABNORMAL, buf);
|
||
- } else if (!breathAbn && g_localBreathAbnActive) {
|
||
- g_localBreathAbnActive = false;
|
||
- g_alarm.clearLocal(AlarmManager::ALARM_BREATH_ABNORMAL);
|
||
+ 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;
|
||
@@ -306,9 +382,10 @@ void checkLocalAlarms(const RadarRawData& radar) {
|
||
g_localWaterActive = false;
|
||
g_alarm.clearLocal(AlarmManager::ALARM_WATER_LEAK);
|
||
}
|
||
+ } // enWater
|
||
|
||
- // ─── ★★ 环境舒适: 温度异常 (防抖3次) ───
|
||
- if (g_sensor.isDhtValid()) {
|
||
+ // ─── ★★ 环境舒适: 温湿度异常 (仅卧室/阳台/厨房, 防抖3次, 30s) ───
|
||
+ if (enEnv && millis() > 30000 && g_sensor.isDhtValid()) {
|
||
float temp = g_sensor.getTemp();
|
||
bool tempAbn = (temp < TEMP_LOW || temp > TEMP_HIGH);
|
||
if (tempAbn) {
|
||
@@ -345,27 +422,27 @@ void checkLocalAlarms(const RadarRawData& radar) {
|
||
}
|
||
}
|
||
|
||
- // ─── ★ 设备:雷达超时 (连续5秒无数据) ───
|
||
+ // ─── 设备:雷达超时 (连续5秒无数据, 仅标记, 不报警) ───
|
||
if (g_radar.isTimeout()) {
|
||
if (++g_radarTimeoutCnt >= 5 && !g_localRadarFaultActive) {
|
||
g_localRadarFaultActive = true;
|
||
- g_alarm.triggerLocal(AlarmManager::ALARM_SENSOR_FAULT, "雷达超时无数据");
|
||
+ Serial.println(F("[系统] ⚠️ 雷达超时无数据"));
|
||
}
|
||
} else {
|
||
g_radarTimeoutCnt = 0;
|
||
if (g_localRadarFaultActive) {
|
||
g_localRadarFaultActive = false;
|
||
- g_alarm.clearLocal(AlarmManager::ALARM_SENSOR_FAULT);
|
||
+ Serial.println(F("[系统] ✅ 雷达数据恢复"));
|
||
}
|
||
}
|
||
|
||
- // ─── ★ 设备:DHT 持续故障 (已由 SensorManager 内部计数) ───
|
||
+ // ─── 设备:DHT 持续故障 (仅标记, 不报警) ───
|
||
if (g_sensor.isDhtFault() && !g_localDhtFaultActive && millis() > 60000) {
|
||
g_localDhtFaultActive = true;
|
||
- g_alarm.triggerLocal(AlarmManager::ALARM_SENSOR_FAULT, "DHT11温湿度传感器故障");
|
||
+ Serial.println(F("[系统] ⚠️ DHT11 温湿度传感器故障"));
|
||
} else if (!g_sensor.isDhtFault() && g_localDhtFaultActive) {
|
||
g_localDhtFaultActive = false;
|
||
- g_alarm.clearLocal(AlarmManager::ALARM_SENSOR_FAULT);
|
||
+ Serial.println(F("[系统] ✅ DHT11 传感器恢复"));
|
||
}
|
||
}
|
||
|
||
@@ -439,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();
|
||
@@ -474,6 +565,9 @@ void setup() {
|
||
// 8. 传感器
|
||
g_sensor.begin();
|
||
|
||
+ // 8. 等雷达 + 电源稳定
|
||
+ delay(8000);
|
||
+
|
||
// 9. 雷达任务 (核心0)
|
||
xTaskCreatePinnedToCore(
|
||
radarTask, "RadarTask",
|
||
@@ -511,6 +605,13 @@ void loop() {
|
||
syncTime();
|
||
wasDisconnected = false;
|
||
}
|
||
+ // 配网成功后更新 MQTT 配置 (如果有改动) 并上报
|
||
+ if (g_wifi.justProvisioned()) {
|
||
+ DeviceConfig cfg = g_wifi.getConfig();
|
||
+ g_mqtt.setServer(cfg.mqtt_server, atoi(cfg.mqtt_port),
|
||
+ cfg.mqtt_user, cfg.mqtt_pass);
|
||
+ g_mqtt.publishProvisionStatus("success", "");
|
||
+ }
|
||
} else {
|
||
wasDisconnected = true;
|
||
}
|
||
@@ -523,34 +624,57 @@ void loop() {
|
||
RadarRawData radar = g_radar.getData();
|
||
checkLocalAlarms(radar);
|
||
|
||
- // --- 传感器故障日志 (报警系统统一管理触发, 此处仅记录) ---
|
||
+ // --- 传感器状态日志 (仅记录, 不报警) ---
|
||
static uint32_t lastFaultLog = 0;
|
||
if (now - lastFaultLog > 60000) {
|
||
lastFaultLog = now;
|
||
- if (!g_sensor.isDhtValid() && millis() > 60000 && !g_localDhtFaultActive) {
|
||
- Serial.println(F("[系统] ⚠️ DHT11 读数异常"));
|
||
+ if (!g_sensor.isDhtValid() && millis() > 60000) {
|
||
+ Serial.println(F("[系统] ⚠️ DHT11 读数失效"));
|
||
}
|
||
- if (g_radar.isTimeout() && !g_localRadarFaultActive) {
|
||
- Serial.println(F("[系统] ⚠️ 雷达超时无数据"));
|
||
+ if (g_radar.isTimeout()) {
|
||
+ 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_alarm.hasActiveAlarm(), g_alarm.getActiveAlarmStr());
|
||
}
|
||
+ lastPresence = radar.presence;
|
||
|
||
// --- 7. MQTT 配网状态 (WiFiManager 触发时) ---
|
||
// WiFiManager 的 captive portal 启动/关闭时更新状态
|
||
diff --git a/src/mqtt_manager.cpp b/src/mqtt_manager.cpp
|
||
index 01afb19..e307c16 100644
|
||
--- a/src/mqtt_manager.cpp
|
||
+++ b/src/mqtt_manager.cpp
|
||
@@ -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";
|
||
@@ -416,6 +421,47 @@ void MQTTManager::publishLocalAlarm(uint8_t alarmType, bool triggered, const cha
|
||
_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());
|
||
+}
|
||
+
|
||
// ============================================================
|
||
// 回调注册
|
||
// ============================================================
|
||
diff --git a/src/radar_manager.cpp b/src/radar_manager.cpp
|
||
index ccd534f..1884d23 100644
|
||
--- a/src/radar_manager.cpp
|
||
+++ b/src/radar_manager.cpp
|
||
@@ -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();
|
||
}
|
||
diff --git a/src/wifi_manager.cpp b/src/wifi_manager.cpp
|
||
index f33eecd..d9da352 100644
|
||
--- a/src/wifi_manager.cpp
|
||
+++ b/src/wifi_manager.cpp
|
||
@@ -1,36 +1,40 @@
|
||
/**
|
||
* @file wifi_manager.cpp
|
||
- * @brief WiFi 配网管理器实现
|
||
+ * @brief WiFi 配网管理器 — AP + WebServer
|
||
*/
|
||
|
||
#include "wifi_manager.h"
|
||
|
||
-// 单例指针
|
||
-WiFiMgmt* WiFiMgmt::_instance = nullptr;
|
||
+// BLE 配网回调: 小程序写入 JSON → 触发配网
|
||
+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());
|
||
+ WiFiMgmt::_instance->handleBLEProvision(val.c_str());
|
||
+ }
|
||
+ }
|
||
+};
|
||
|
||
-// ============================================================
|
||
-// 构造函数 & 析构函数
|
||
-// ============================================================
|
||
+WiFiMgmt* WiFiMgmt::_instance = nullptr;
|
||
|
||
WiFiMgmt::WiFiMgmt()
|
||
- : _connected(false)
|
||
- , _lastCheckTime(0)
|
||
- , _buttonPressTime(0)
|
||
- , _buttonPressed(false)
|
||
- , _ledTaskHandle(nullptr)
|
||
- , _ledMode(0)
|
||
- , _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)
|
||
{
|
||
memset(&_config, 0, sizeof(_config));
|
||
- _instance = this; // 设置单例
|
||
+ _instance = this;
|
||
}
|
||
|
||
WiFiMgmt::~WiFiMgmt() {
|
||
+ _stopPortal();
|
||
_instance = nullptr;
|
||
}
|
||
|
||
@@ -39,176 +43,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);
|
||
|
||
- // 启动 LED 独立任务 (避免 startConfigPortal 阻塞影响闪烁)
|
||
xTaskCreate(_ledTaskFunc, "WiFiLed", 1024, nullptr, 1, &_ledTaskHandle);
|
||
|
||
- // 初始化 LittleFS
|
||
+ // 冷启动延迟, 等 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>"
|
||
- /* ===== 字体与全局 ===== */
|
||
- "*{margin:0;padding:0;box-sizing:border-box}"
|
||
- "body{"
|
||
- " font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;"
|
||
- " background:linear-gradient(160deg, #f0f5ff 0%, #e8f0fe 40%, #f3f0ff 100%);"
|
||
- " min-height:100vh;display:flex;flex-direction:column;align-items:center;"
|
||
- " padding:20px 16px 40px;color:#1e293b"
|
||
- "}"
|
||
- "body::before{"
|
||
- " content:'';position:fixed;top:-200px;right:-200px;"
|
||
- " width:400px;height:400px;border-radius:50%;"
|
||
- " background:radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);"
|
||
- " pointer-events:none;z-index:0"
|
||
- "}"
|
||
- "body::after{"
|
||
- " content:'';position:fixed;bottom:-100px;left:-100px;"
|
||
- " width:300px;height:300px;border-radius:50%;"
|
||
- " background:radial-gradient(circle, rgba(34,197,94,0.06) 0%, transparent 70%);"
|
||
- " pointer-events:none;z-index:0"
|
||
- "}"
|
||
- /* ===== 头部卡片 ===== */
|
||
- ".msg{"
|
||
- " width:100%;max-width:440px;position:relative;z-index:1;"
|
||
- " background:#fff;border-radius:20px;padding:32px 24px 24px;"
|
||
- " margin-bottom:16px;text-align:center;"
|
||
- " box-shadow:0 1px 3px rgba(0,0,0,0.04),0 8px 24px rgba(0,0,0,0.06)"
|
||
- "}"
|
||
- ".msg::before{"
|
||
- " content:'';display:block;width:56px;height:56px;"
|
||
- " margin:0 auto 16px;"
|
||
- " background:linear-gradient(135deg,#6366f1,#8b5cf6);border-radius:16px;"
|
||
- " box-shadow:0 8px 24px rgba(99,102,241,0.25)"
|
||
- "}"
|
||
- ".msg h1,.msg h2{font-size:20px;font-weight:700;color:#1e293b;margin-bottom:6px}"
|
||
- ".msg p,.msg .info{font-size:13px;color:#94a3b8;line-height:1.5}"
|
||
- /* 隐藏 WiFiManager 默认的 h1 重复标题 */
|
||
- ".msg h1{font-size:0;margin:0;padding:0}"
|
||
- ".msg h1::after{content:'健康监测系统';font-size:22px;font-weight:700;color:#1e293b}"
|
||
- ".msg .info{font-size:13px!important}"
|
||
- /* ===== 表单卡片 ===== */
|
||
- "form{"
|
||
- " width:100%;max-width:440px;position:relative;z-index:1;"
|
||
- " background:#fff;border-radius:20px;padding:24px;"
|
||
- " box-shadow:0 1px 3px rgba(0,0,0,0.04),0 8px 24px rgba(0,0,0,0.06)"
|
||
- "}"
|
||
- "label{"
|
||
- " display:block;font-size:11px;font-weight:600;"
|
||
- " text-transform:uppercase;letter-spacing:0.8px;"
|
||
- " color:#6366f1;margin:16px 0 6px"
|
||
- "}"
|
||
- "label:first-of-type{margin-top:0}"
|
||
- "input[type=text],input[type=password],input[type=number]{"
|
||
- " width:100%;height:46px;padding:0 14px;"
|
||
- " border:2px solid #e2e8f0;border-radius:12px;"
|
||
- " font-size:15px;color:#1e293b;background:#f8fafc;"
|
||
- " outline:none;transition:all 0.2s;-webkit-appearance:none"
|
||
- "}"
|
||
- "input:focus{"
|
||
- " border-color:#6366f1;background:#fff;"
|
||
- " box-shadow:0 0 0 4px rgba(99,102,241,0.1)"
|
||
- "}"
|
||
- "input::placeholder{color:#cbd5e1}"
|
||
- /* ===== 提交按钮 ===== */
|
||
- "button[type=submit]{"
|
||
- " width:100%;height:50px;margin-top:24px;"
|
||
- " background:linear-gradient(135deg,#6366f1,#8b5cf6);"
|
||
- " color:#fff;border:none;border-radius:14px;"
|
||
- " font-size:16px;font-weight:600;letter-spacing:1px;"
|
||
- " cursor:pointer;transition:all 0.2s;"
|
||
- " box-shadow:0 4px 16px rgba(99,102,241,0.3)"
|
||
- "}"
|
||
- "button:active{transform:scale(0.97);box-shadow:0 2px 8px rgba(99,102,241,0.3)}"
|
||
- /* ===== 页脚 ===== */
|
||
- ".msg:last-of-type{"
|
||
- " background:transparent;box-shadow:none;padding:16px;margin-top:4px"
|
||
- "}"
|
||
- ".msg:last-of-type::before{display:none}"
|
||
- ".msg:last-of-type p,.msg:last-of-type .info{font-size:12px;color:#94a3b8}"
|
||
- /* ===== 成功/错误状态页 ===== */
|
||
- ".msg .success{color:#16a34a}"
|
||
- ".msg .error{color:#dc2626}"
|
||
- "a{color:#6366f1;text-decoration:none;font-weight:500}"
|
||
- "</style>"
|
||
- );
|
||
- WiFi.setAutoReconnect(true); // ESP32 自动重连
|
||
-
|
||
- // 注册保存回调 (必须使用静态函数, 不能使用 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();
|
||
+ WiFi.setAutoReconnect(true);
|
||
+
|
||
+ // 有配置 → 直接连
|
||
+ 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(2); // 快闪 = 配网模式
|
||
- _connected = _connectWiFi(); // 内部会启动配网页面
|
||
- }
|
||
-
|
||
- if (_connected) {
|
||
- _setLEDMode(3); // 常亮 = 已连接
|
||
+ _startPortal(); // 只开 AP
|
||
+ } else {
|
||
+ _setLEDMode(3); // 常亮
|
||
}
|
||
|
||
_lastCheckTime = millis();
|
||
@@ -220,29 +101,59 @@ bool WiFiMgmt::begin() {
|
||
// ============================================================
|
||
|
||
bool WiFiMgmt::loop() {
|
||
- // 检测按钮长按
|
||
+ // --- Portal 模式 ---
|
||
+ if (_portalActive) {
|
||
+ if (_portalStopFlag) {
|
||
+ _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; // 标记配网成功
|
||
+ } else {
|
||
+ _setLEDMode(2);
|
||
+ _startPortal();
|
||
+ }
|
||
+ }
|
||
+ return _connected;
|
||
+ }
|
||
+
|
||
+ _dns->processNextRequest();
|
||
+ _server->handleClient();
|
||
+ 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(1); // 慢闪 = 未连接
|
||
- 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(3); // 常亮 = 已连接
|
||
+ _setLEDMode(3);
|
||
+ Serial.printf("[WiFi] 已连接 RSSI=%d\n", WiFi.RSSI());
|
||
}
|
||
}
|
||
|
||
- // LED 由独立 FreeRTOS 任务处理 (不受 startConfigPortal 阻塞影响)
|
||
return _connected;
|
||
}
|
||
|
||
@@ -251,128 +162,375 @@ 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;
|
||
}
|
||
|
||
+bool WiFiMgmt::_tryConnect(const char* ssid, const char* pass) {
|
||
+ WiFi.mode(WIFI_STA);
|
||
+ 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() {
|
||
- Serial.println(F("[WiFi] 🔧 手动启动配网页面..."));
|
||
- _setLEDMode(2); // 快闪 = 配网模式
|
||
- _wm.startConfigPortal(WIFI_AP_SSID, WIFI_AP_PASSWORD);
|
||
- _connected = (WiFi.status() == WL_CONNECTED);
|
||
- if (_connected) {
|
||
- _setLEDMode(3); // 常亮 = 已连接
|
||
+ _setLEDMode(2);
|
||
+ _startPortal();
|
||
+}
|
||
+
|
||
+void WiFiMgmt::_startPortal() {
|
||
+ if (_portalActive) return;
|
||
+
|
||
+ WiFi.disconnect(true);
|
||
+ WiFi.mode(WIFI_OFF);
|
||
+ delay(200);
|
||
+ WiFi.mode(WIFI_AP);
|
||
+ WiFi.softAPConfig(IPAddress(192,168,4,1), IPAddress(192,168,4,1), IPAddress(255,255,255,0));
|
||
+ WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASSWORD);
|
||
+ Serial.printf("[WiFi] AP: %s IP: 192.168.4.1\n", WIFI_AP_SSID);
|
||
+
|
||
+ // 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_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::resetSettings() {
|
||
- Serial.println(F("[WiFi] 清除所有配置..."));
|
||
+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();
|
||
+}
|
||
|
||
- // 删除 LittleFS 中的配置文件
|
||
- 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);
|
||
+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><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() {
|
||
+ if (_bleServer != nullptr) return; // 已启动
|
||
+
|
||
+ BLEDevice::init("HealthMonitor");
|
||
+ _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();
|
||
+ _bleAdv->addServiceUUID("4fafc201-1fb5-459e-8fcc-c5c9c331914b");
|
||
+ _bleAdv->setScanResponse(true);
|
||
+ _bleAdv->start();
|
||
+
|
||
+ Serial.printf("[BLE] ✅ 配网广播: HealthMonitor device_id=%s\n", _config.device_id);
|
||
+}
|
||
+
|
||
+void WiFiMgmt::_stopBLE() {
|
||
+ if (!_bleServer) return;
|
||
+ if (_bleAdv) { _bleAdv->stop(); _bleAdv = nullptr; }
|
||
+ BLEDevice::deinit(false);
|
||
+ _bleServer = nullptr;
|
||
+ _bleChar = nullptr;
|
||
+ Serial.println("[BLE] 已停止");
|
||
+}
|
||
+
|
||
+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;
|
||
+ }
|
||
|
||
- // 清除 WiFiManager 的配置
|
||
- _wm.resetSettings();
|
||
+ // 提取 WiFi 凭证
|
||
+ const char* ssid = doc["s"] | "";
|
||
+ const char* pass = doc["p"] | "";
|
||
|
||
- // LED 快闪提示
|
||
- for (int i = 0; i < 10; i++) {
|
||
- digitalWrite(LED_WIFI_PIN, !digitalRead(LED_WIFI_PIN));
|
||
- delay(100);
|
||
+ if (strlen(ssid) == 0) {
|
||
+ Serial.println("[BLE] 未提供 WiFi SSID");
|
||
+ if (_bleChar) { _bleChar->setValue("ERR_NO_SSID"); _bleChar->notify(); }
|
||
+ return;
|
||
}
|
||
|
||
- Serial.println(F("[WiFi] 🔄 正在重启..."));
|
||
+ _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(("[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);
|
||
+ 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;
|
||
}
|
||
|
||
@@ -384,134 +542,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 保存参数回调
|
||
-// ============================================================
|
||
-
|
||
-// ============================================================
|
||
-// 静态回调函数 (WiFiManager 要求 C 函数指针)
|
||
+// Getter
|
||
// ============================================================
|
||
|
||
-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);
|
||
-}
|
||
-
|
||
-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) {
|
||
- _ledMode = mode; // uint8_t 赋值在 ESP32 上是原子的
|
||
-}
|
||
-
|
||
-// ============================================================
|
||
-// LED 闪烁独立任务 (FreeRTOS)
|
||
-// 独立于主循环,startConfigPortal 阻塞期间也能正常闪烁
|
||
-// ============================================================
|
||
+void WiFiMgmt::_setLEDMode(uint8_t mode) { _ledMode = mode; }
|
||
|
||
void WiFiMgmt::_ledTaskFunc(void* param) {
|
||
- const TickType_t fastMs = pdMS_TO_TICKS(300); // 快闪 = 配网模式
|
||
- const TickType_t slowMs = pdMS_TO_TICKS(1000); // 慢闪 = 未连接
|
||
-
|
||
+ const TickType_t fastMs = pdMS_TO_TICKS(300);
|
||
+ const TickType_t slowMs = pdMS_TO_TICKS(1000);
|
||
while (true) {
|
||
- if (!_instance) {
|
||
- vTaskDelay(slowMs);
|
||
- continue;
|
||
- }
|
||
-
|
||
+ if (!_instance) { vTaskDelay(slowMs); continue; }
|
||
uint8_t mode = _instance->_ledMode;
|
||
-
|
||
switch (mode) {
|
||
- case 0: // 灭
|
||
- digitalWrite(LED_WIFI_PIN, LOW);
|
||
- vTaskDelay(slowMs);
|
||
- break;
|
||
- case 1: // 慢闪 — WiFi 断连
|
||
- 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;
|
||
+ 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;
|
||
}
|
||
}
|
||
}
|