This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.

Summary:
1. Primary Request and Intent:
   - Continue previous chat about ESP32-S3 health monitoring system
   - Configure second board for living room deployment (env_type needed in provisioning)
   - Flash third board for bathroom with AFD1 radar (fall detection focused)
   - Fix BLE provisioning crash (stack overflow in BTC_TASK)
   - Fix BLE not discoverable by WeChat mini program
   - Fix BLE disappearing after WiFi connects (can't re-enter provisioning)
   - Latest issue: After the keep-BLE-alive fix, BLE signal not found at all

2. Key Technical Concepts:
   - ESP32-S3 PlatformIO Arduino project
   - R60ABD1 (heart rate/respiration/sleep) and R60AFD1 (fall detection) mmWave radar modules
   - WiFi provisioning via AP mode + Captive Portal (WebServer + DNSServer) + BLE
   - MQTT protocol (遥测上报, 告警, 配网)
   - BLE GATT: Service UUID `4fafc201-1fb5-459e-8fcc-c5c9c331914b`, Characteristics `4fafc202` (READ), `4fafc203` (WRITE+NOTIFY)
   - FreeRTOS dual-core: BTC_TASK (core 0, BLE stack), main loop (core 1)
   - Stack overflow: BTC_TASK too small for Flash write + BLE notify operations
   - BLE advertising: main packet vs scan response, passive vs active scanning
   - ESP32 BLE limitation: `BLEDevice::deinit()` cannot be followed by `BLEDevice::init()` without reboot
   - Environment types: bedroom, livingroom, bathroom, hallway, balcony, kitchen — each with different alarm policies
   - Conditional compilation: `#if RADAR_TYPE != RADAR_TYPE_AFD1` guards heart/respiration code
   - ArduinoJson deprecated `containsKey()` — use `doc["key"].is<T>()` instead

3. Files and Code Sections:
   - **include/config.h**
     - Radar type selection, device IDs, WiFi/MQTT defaults, alarm thresholds
     - Changed: `#define RADAR_TYPE RADAR_TYPE_AFD1` (was ABD1) for bathroom board
     - Pin definitions: RADAR_TX=1, RADAR_RX=17, DHT11=4, BUZZER=21, OLED=15/16, etc.

   - **include/wifi_manager.h**
     - DeviceConfig struct, WiFiMgmt class declaration
     - Added to header:
     ```cpp
     void enqueueBLEJson(const char* json);       // BLE 线程安全入队 (仅暂存)
     // In private section:
     String        _blePendingJson;      // 待处理的 BLE 配网 JSON
     bool          _blePendingFlag;      // 有未处理的 BLE 数据
     void _processBLEPending();          // 在 loop() 上下文处理
     ```

   - **src/wifi_manager.cpp** — Most changes:
     - **BLE callback (lines 8-18)**: Changed from calling `handleBLEProvision()` directly to calling `enqueueBLEJson()`:
     ```cpp
     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->enqueueBLEJson(val.c_str());
             }
         }
     };
     ```
     - **Constructor**: Added `_blePendingFlag(false)` initializer
     - **loop()**: Added `_processBLEPending();` as first line
     - **HTML form**: Added env_type dropdown (6 options: bedroom/livingroom/bathroom/hallway/balcony/kitchen with Chinese labels + emoji)
     - **Save handler**: Added `env_type` field to `_handleSave()`
     - **enqueueBLEJson + _processBLEPending** (inserted before handleBLEProvision):
     ```cpp
     void WiFiMgmt::enqueueBLEJson(const char* json) {
         _blePendingJson  = json;
         _blePendingFlag  = true;
     }
     void WiFiMgmt::_processBLEPending() {
         if (!_blePendingFlag) return;
         _blePendingFlag = false;
         Serial.printf("[BLE] loop 处理配网: %s\n", _blePendingJson.c_str());
         handleBLEProvision(_blePendingJson.c_str());
         _blePendingJson = "";
     }
     ```
     - **BLE advertising** (in _startBLE): Changed from scan response to main advertising packet:
     ```cpp
     BLEAdvertisementData advData;
     advData.setCompleteServices(BLEUUID("4fafc201-1fb5-459e-8fcc-c5c9c331914b"));
     advData.setShortName("HealthMon");
     _bleAdv->setAdvertisementData(advData);
     _bleAdv->setScanResponse(false);
     _bleAdv->start();
     ```
     - **_startBLE()**: Added restart path:
     ```cpp
     void WiFiMgmt::_startBLE() {
         if (_bleServer != nullptr) {
             if (_bleAdv) { _bleAdv->start(); }
             Serial.println("[BLE] ✅ 广播已重开");
             return;
         }
         BLEDevice::init("HealthMonitor");
         // ... full init
     }
     ```
     - **_stopBLE()**: Removed deinit, keeps stack alive:
     ```cpp
     void WiFiMgmt::_stopBLE() {
         if (!_bleServer) return;
         if (_bleAdv) { _bleAdv->stop(); }
         Serial.println("[BLE] 广播已暂停");
     }
     ```

   - **platformio.ini**: upload_port changed from COM20 to COM19

   - **src/main.cpp**: Read to understand alarm logic per environment type; `checkLocalAlarms()` uses env_type to gate fall/vital/water/env alarms

4. Errors and fixes:
   - **COM20 not found**: Changed `upload_port` in platformio.ini from COM20 to COM19
   - **Stack overflow crash on BLE provision**:
     - `Guru Meditation Error: Core 0 panic'ed (Unhandled debug exception). Stack canary watchpoint triggered (BTC_TASK)`
     - Fix: Deferred BLE processing via `enqueueBLEJson()` → `_processBLEPending()` in loop context
   - **WeChat mini program can't find BLE (but system Bluetooth can)**:
     - Root cause: UUID in scan response only, WeChat uses passive scanning
     - Fix: Put UUID in main advertising packet via `setAdvertisementData()`, `setScanResponse(false)`
   - **BLE signal disappears on second provisioning attempt**:
     - Root cause: `BLEDevice::deinit(false)` → cannot re-`init()` on ESP32
     - Attempted fix: Keep BLE stack alive, only stop/start advertising
     - **Unresolved**: User now reports BLE not found at all ("现在又找不到蓝牙信号了")
   - **ArduinoJson deprecation warnings** (`containsKey`): Not fixed, only warnings
   - **IDE diagnostic false positive**: `_blePendingJson` and `_blePendingFlag` shown as inaccessible — stale diagnostics, resolved by adding public `enqueueBLEJson()` method

5. Problem Solving:
   - **Solved**: env_type dropdown for web provisioning (living room/bathroom deployment)
   - **Solved**: Stack overflow crash during BLE provisioning (deferred processing)
   - **Solved**: WeChat mini program BLE scanning (UUID in main advertising packet)
   - **Partially solved, then broke**: BLE restart after WiFi connection
     - Original: deinit prevents re-init → no BLE on second provisioning
     - Fix: Keep BLE stack alive → now BLE not discoverable at all
     - This needs further investigation — possible issues: WiFi/BLE coexistence when STA is connected, `_bleAdv->stop()`/`start()` cycle not working, or `_startPortal()` WiFi mode switch interfering
   - **Solved**: AFD1 compilation verified (conditional compilation paths correct)

6. All user messages:
   - "继续上次的聊天" (continue previous chat)
   - "先说项目，我又焊接了一块，打算放在，客厅的" (let's talk about the project, I soldered another board for the living room)
   - "插上反复断开连接" (plugging in causes repeated disconnection) — resolved by re-plugging
   - "好了 烧录进去了" (ok, burned in)
   - "现在要增加一个手环，你觉得该怎么协调" (adding a wristband, how to coordinate?) — discussed radar vs band capabilities, paused
   - "下班" (going home)
   - "现在我要烧录，卫生间的afd1的板子" (now I want to flash the bathroom AFD1 board)
   - "我用蓝牙配网" (I'll use BLE for provisioning)
   - "蓝牙配网后[crash log]...他就会又去连接上一个" (after BLE provision crashes, it reconnects to the old one)
   - "小程序连接 BLE [flow diagram]" — asking if the BLE flow is correct
   - "为啥蓝牙在手机中能搜索到，在小程序扫描不到" (why can phone find BLE but mini program can't)
   - "能配上了，但是连接上以后，再次按下配网的话，就没有蓝牙信号了" (provisioning works, but after connecting, pressing button again gives no BLE)
   - "现在又找不到蓝牙信号了" (now can't find BLE signal at all) — CURRENT ISSUE, UNRESOLVED

7. Pending Tasks:
   - **URGENT**: Fix BLE not discoverable at all after the keep-BLE-alive change
   - Living room board: flash firmware (currently RADAR_TYPE=AFD1), needs to be changed back to ABD1
   - Wristband/band integration: discuss in detail when user returns
   - ArduinoJson `containsKey` deprecation warnings cleanup (non-critical)

8. Current Work:
   We were debugging BLE provisioning issues on the bathroom board (AFD1). The progression was:
   1. BLE crash (stack overflow) → fixed with deferred processing
   2. WeChat mini program can't scan BLE → fixed with main advertising packet
   3. BLE disappears on second provisioning → attempted fix: keep BLE stack alive
   4. **Now**: After the keep-BLE-alive fix, BLE not discoverable at all

   The last two edits to `src/wifi_manager.cpp` changed `_startBLE()` to check for existing server and restart advertising, and changed `_stopBLE()` to only stop advertising without deinit. The user's last message "现在又找不到蓝牙信号了" suggests this fix broke BLE entirely.

   Possible causes for current failure:
   - The `_startBLE()` "restart advertising" path (`if (_bleServer != nullptr)`) may not work when called from a cold boot (constructor sets `_bleServer = nullptr`, so first init should be fine)
   - More likely: on the re-provision path, `_bleAdv` was stopped but not properly restarted — `_bleAdv->start()` after `_bleAdv->stop()` might need reconfiguration
   - WiFi/BLE coexistence: if WiFi STA is connected when `_startPortal()` runs, switching to AP+BLE might have timing issues
   - The `_startPortal()` function calls `WiFi.mode(WIFI_OFF)` then `WiFi.mode(WIFI_AP)` — this might interfere with BLE
   - Need to verify whether the firmware was actually burned after the last code changes

9. Optional Next Step:
   Investigate why BLE is not discoverable at all after the keep-alive changes. Two approaches:

   **A. Roll back to working state + proper fix:** Restore `_stopBLE()` to deinit/null everything, but in `_startBLE()`, DON'T guard with `if (_bleServer != nullptr)` — always do full init. The original deinit-on-stop is the correct approach for cold boot; the issue was only about re-entering provisioning WITHOUT reboot. The fix should be: keep deinit in `_stopBLE()`, but in `_startPortal()`, if this is a re-entry (portal was started before on this boot), reboot the ESP32 before restarting portal. Or simpler: keep BLE alive during the entire session but deinit on `_stopPortal()`.

   **B. Check what was actually burned:** The user may not have burned the latest firmware with the keep-alive fix. Need to verify and then investigate whether the issue is the original "BLE disappears on second press" or a new regression.

   The user's last request was "现在又找不到蓝牙信号了" — they want BLE to work. The immediate next step should be to diagnose whether this is a cold-boot failure (all BLE broken) or a re-entry failure (second provisioning broken), and either roll back the broken change or properly fix the restart logic.

If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: C:\Users\杨志佳\.claude\projects\d--PlatformIO1-Projects-Healthv2-0\861a1013-414b-44f3-b2a2-ee22ec67de60.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.


/**
 * @file wifi_manager.h
 * @brief WiFi 配网管理器 (AP + WebServer)
 *
 * 功能:
 *  - 自动连接已保存的 WiFi
 *  - 配网失败/首次启动 → AP 模式 + Captive Portal
 *  - 自定义参数: MQTT 服务器/端口/用户/密码、设备名称、环境备注
 *  - 长按按钮重置配置
 *  - LED 状态指示
 *  - LittleFS 持久化存储
 */

#ifndef WIFI_MANAGER_H
#define WIFI_MANAGER_H

#include <Arduino.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"

// 设备配置结构体
struct DeviceConfig {
    char mqtt_server[40];
    char mqtt_port[6];
    char mqtt_user[20];
    char mqtt_pass[20];
    char device_id[20];
    char env_note[20];      // 环境备注 (房间名)
    char env_type[16];      // 环境类型 bedroom/bathroom/...
    char wifi_ssid[33];     // WiFi SSID
    char wifi_pass[65];     // WiFi 密码
};

class WiFiMgmt {
public:
    WiFiMgmt();
    ~WiFiMgmt();

    // 单例指针 (用于静态回调)
    static WiFiMgmt* _instance;

    bool begin();
    bool loop();
    bool isConnected() const;
    bool justProvisioned();     // 刚配网成功 (读取后自动清除)
    DeviceConfig getConfig() const;
    const char* getDeviceId() const;
    const char* getEnvNote() const;
    void startConfigPortal();
    void handleBLEProvision(const char* json);  // BLE 配网回调
    void enqueueBLEJson(const char* json);       // BLE 线程安全入队 (仅暂存)
    void resetSettings();

private:
    bool _connectWiFi();          // 仅尝试已保存凭据
    bool _tryConnect(const char* ssid, const char* pass);  // 尝试连接指定 WiFi
    bool _loadConfig();
    bool _saveConfig();
    void _setLEDMode(uint8_t mode);
    static void _ledTaskFunc(void* param);
    bool _checkButtonLongPress();

    // 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=常亮

    // BLE
    BLEServer*           _bleServer;
    BLECharacteristic*   _bleInfoChar;   // READ: 设备信息
    BLECharacteristic*   _bleChar;       // WRITE+NOTIFY: 配网
    BLEAdvertising*      _bleAdv;

    // BLE 回调保护: 不在 BTC_TASK 栈里做 Flash 写操作
    String        _blePendingJson;      // 待处理的 BLE 配网 JSON
    bool          _blePendingFlag;      // 有未处理的 BLE 数据
    void _processBLEPending();          // 在 loop() 上下文处理
};

#endif // WIFI_MANAGER_H


; PlatformIO Project Configuration File
; ESP32-S3 + R60ABD1 60G 毫米波雷达 健康监测系统

[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino

; 串口监视器
monitor_speed = 115200
monitor_filters = direct
monitor_rts = 0
monitor_dtr = 0

; Flash 配置
board_build.flash_mode = qio
board_build.filesystem = littlefs
board_build.partitions = default_8MB.csv
board_build.arduino.memory_type = qio_opi

; 编译选项 - 优化速度
build_flags =
    -Os
    -DCORE_DEBUG_LEVEL=0
    -DARDUINO_USB_CDC_ON_BOOT=1
    -DBOARD_HAS_PSRAM

; 库依赖
lib_deps =
    ; MQTT 客户端
    knolleary/PubSubClient@^2.8

    ; JSON 解析 (更省内存)
    bblanchon/ArduinoJson@^7.1.0

    ; DHT11 温湿度传感器
    adafruit/DHT sensor library@^1.4.6
    adafruit/Adafruit Unified Sensor@^1.1.14

    ; OLED 显示屏 (SSD1306 128x64 I2C)
    olikraus/U8g2@^2.35.30

; 上传配置
upload_speed = 921600
upload_port = COM19


/**
 * @file config.h
 * @brief 全局配置文件 - 五劳无感康养监测系统
 *
 * 协议版本: V0.08 (2026-07-17)
 * 包含: 硬件引脚定义、WiFi/MQTT 默认参数、
 *        雷达协议常量、告警阈值、任务配置等
 */

#ifndef CONFIG_H
#define CONFIG_H

#include <Arduino.h>

// ============================================================
// 1. 硬件引脚定义 (ESP32-S3)
// ============================================================

// --- 雷达型号选择 ---
#define RADAR_TYPE_ABD1       0   // R60ABD1: 心率/呼吸精准, 跌倒一般
#define RADAR_TYPE_AFD1       1   // R60AFD1: 跌倒/姿态精准, 无心率呼吸
#define RADAR_TYPE            RADAR_TYPE_AFD1  // AFD1 (跌倒/姿态精准)

// --- 毫米波雷达 ---
#define RADAR_UART_PORT       Serial2
#define RADAR_RX_PIN          17
#define RADAR_TX_PIN          1
#define RADAR_BAUD_RATE       115200

// --- 雷达备用 GPIO ---
#define RADAR_GPIO1_PIN       2             // 活跃/静止指示 (从10挪到2)
#define RADAR_GPIO2_PIN       11            // 有人/无人指示

// --- DHT11 温湿度传感器 ---
#define DHT11_PIN             4             // 温湿度传感器

// --- 水浸传感器 ---
#define WATER_SENSOR_PIN      45            // 数字输入, 低电平=有水

// --- 蜂鸣器 (PWM) ---
#define BUZZER_PIN            21
#define BUZZER_PWM_CHANNEL    0
#define BUZZER_PWM_FREQ       2000
#define BUZZER_PWM_RESOLUTION 8

// --- 配网按钮 ---
#define CONFIG_BUTTON_PIN     0             // 长按 3 秒复位 WiFi
#define BUTTON_LONG_PRESS_MS  3000

// --- 状态指示灯 ---
#define LED_WIFI_PIN          8             // 蓝色 - WiFi 状态
#define LED_ALARM_PIN         9             // 红色 - 报警指示

// --- OLED 显示屏 (SSD1306 128x64 I2C) ---
#define OLED_SDA_PIN          15            // I2C 数据线
#define OLED_SCL_PIN          16            // I2C 时钟线

// --- 交互按钮 ---
#define BTN_PROVISION_PIN     14            // 擦除WiFi配网按钮 (长按3秒)
#define BTN_ALARM_DISMISS_PIN 13            // 解除报警按钮
#define BTN_PAGE_SWITCH_PIN   46            // 翻页按钮
#define BTN_DEBOUNCE_MS       50            // 去抖时间
#define BTN_LONG_PRESS_MS     1500          // 长按判定

// ============================================================
// 2. 设备标识 (协议规范 §2)
// ============================================================

#define SALEID_DEFAULT        "10100001"    // 站点/系统编号
#define GATEID_DEFAULT        "10100001001" // 网关唯一编号
#define METER_ID_DEFAULT      "ENV001"      // 感知节点ID (卧室=ENV001, 卫生间=ENV002, 客厅=ENV003)
#define METER_NAME_DEFAULT    "RadarDHT11"  // 设备型号名称
#define FW_VERSION            "V1.0.0"

// ============================================================
// 3. WiFi 默认配置 (配网使用)
// ============================================================

#define WIFI_AP_SSID          "HealthMonitor-Setup"
#define WIFI_AP_PASSWORD      "1234567890"
#define WIFI_CONFIG_TIMEOUT   0   // 0=不超时, 一直等待配置
#define WIFI_CONNECT_TIMEOUT  15
#define WIFI_RETRY_INTERVAL   10000

// ============================================================
// 4. MQTT 默认配置 (协议规范 §2)
// ============================================================

#define MQTT_DEFAULT_SERVER   "202.110.234.102"
#define MQTT_DEFAULT_PORT     8000
#define MQTT_DEFAULT_PORT_STR "8000"         // WiFiManager 参数用字符串
#define MQTT_DEFAULT_USER     "test"
#define MQTT_DEFAULT_PASS     "testtest"
#define MQTT_DEFAULT_DEVICE   "10100001001"  // 默认网关ID = gateid
#define MQTT_DEFAULT_ENV      "主卧"           // 默认环境备注
#undef MQTT_KEEPALIVE
#define MQTT_KEEPALIVE        30              // 30秒 (协议规范)
#define MQTT_RECONNECT_MIN    1000
#define MQTT_RECONNECT_MAX    60000

// ============================================================
// 5. MQTT 主题定义 (协议规范 §3)
// ============================================================

// --- 上行主题 (设备 → 服务端) ---
#define MQTT_TOPIC_REPORT_ALLPOINTS   "report/allpoints"                // 全量遥测上报 QoS0
#define MQTT_TOPIC_STATUS_ONLINE      "status/%s/online"                // 上线通知 QoS1
#define MQTT_TOPIC_STATUS_OFFLINE     "status/%s/offline"               // 离线遗嘱 QoS1
#define MQTT_TOPIC_STATUS_PROVISION   "status/%s/provision"             // 配网状态 QoS1
#define MQTT_TOPIC_RESPONSE_CMD       "response/%s/cmd"                 // 指令响应 QoS1
#define MQTT_TOPIC_RESPONSE_PROVISION "response/%s/provision"           // 配网响应 QoS1

// --- 下行主题 (服务端 → 设备) ---
#define MQTT_TOPIC_CMD_ALARM          "cmd/%s/alarm"                    // 告警指令 QoS1
#define MQTT_TOPIC_CMD_ALARM_RESTORE  "cmd/%s/alarm/restore"            // 告警恢复 QoS1
#define MQTT_TOPIC_CMD_CONTROL        "cmd/%s/control"                  // 控制指令 QoS1
#define MQTT_TOPIC_CMD_PROVISION_WIFI "cmd/%s/provision/wifi"           // WiFi配网 QoS1

// ============================================================
// 6. 上报配置 (协议规范 §4.1, §7.2)
// ============================================================

#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

// ============================================================
// 7. 报警阈值 (协议规范 §6, 服务端判定用)
// ============================================================

#define HEART_RATE_MIN        50
#define HEART_RATE_MAX        120
#define BREATH_RATE_MIN       8
#define BREATH_RATE_MAX       24
#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

// ============================================================
// 8. 任务配置
// ============================================================

#define RADAR_TASK_STACK      4096
#define RADAR_TASK_PRIORITY   5
#define RADAR_TASK_CORE       0

#define MQTT_TASK_STACK       8192
#define MQTT_TASK_PRIORITY    4
#define MQTT_TASK_CORE        1

// ============================================================
// 9. NTP 时间同步
// ============================================================

#define NTP_SERVER            "ntp.aliyun.com"
#define NTP_GMT_OFFSET_SEC    28800             // UTC+8 (北京时间)
#define NTP_DAYLIGHT_OFFSET   0

// ============================================================
// 10. LittleFS 配置文件名
// ============================================================

#define CONFIG_FILE_MQTT      "/mqtt_config.json"
#define CONFIG_FILE_WIFI      "/wifi_config.json"
#define CONFIG_FILE_DEVICE    "/device_config.json"

// ============================================================
// 11. 结构体: 全量遥测数据 (协议规范 §4.1)
// ============================================================

struct TelemetryData {
    // --- 设备标识 ---
    char     saleid[12];          // 站点编号 (已弃用)
    char     gateid[16];          // 网关编号
    char     meterId[8];          // 节点ID (已弃用, 新用 gateid+后缀)
    char     meterName[16];       // 设备型号 "RadarDHT11"
    char     deviceType[16];      // 设备类型枚举 bedroom/bathroom/...

    // --- 环境数据 (DHT11) ---
    float    temp;                // 温度 ℃
    float    humi;                // 湿度 %RH
    bool     dhtValid;            // 温湿度有效性

    // --- 雷达: 存在/运动 ---
    bool     presence;            // 有人=1 无人=0
    bool     motion;              // 有运动=1 无运动=0
    bool     fall;                // 跌倒检测 1=触发
    bool     resident;            // 驻留状态 1=驻留

    // --- 雷达: 生命体征 ---
    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;          // 心率呼吸有效性

    // --- 水浸 ---
    bool     rainDO;              // 1=正常 0=水浸触发
    bool     rainValid;           // 水浸传感器有效性

    // --- 设备状态 ---
    bool     wifiReady;           // WiFi/MQTT 连接状态
    uint32_t uptimeSec;           // 设备累计运行秒数
    bool     buzzerState;         // 蜂鸣器状态 1=开启

    // --- 时间戳 ---
    uint32_t lastUpdate;          // 最后更新 ms
};

// 结构体: 下行报警指令 (协议规范 §5.1, §5.2)
struct AlarmCommand {
    char     cmdId[32];           // 指令唯一ID
    char     type[20];            // alarm / alarm_restore
    char     alarmType[24];       // fall/water_leak/heart_abnormal/breath_abnormal
    char     alarmLevel[8];       // low/medium/high
    char     alarmDesc[64];       // 告警描述
    char     meterId[8];          // 触发节点ID
    char     relatedCmdId[32];    // 关联原始告警ID (恢复用)

    // 联动动作
    bool     buzzerOn;            // 蜂鸣器开关
    uint16_t buzzerDuration;      // 蜂鸣持续秒数 (0=持续到恢复)
    bool     nightLight;          // 夜灯

    uint32_t timestamp;
};

// 结构体: 下行控制指令 (协议规范 §5.3)
struct ControlCommand {
    char     cmdId[32];
    char     type[20];            // control
    char     controlType[24];     // buzzer_test/device_restart/report_interval_set/factory_reset
    uint32_t params_duration;     // 参数: 持续秒数
    uint32_t params_interval;     // 参数: 上报间隔
    uint32_t timestamp;
};

// 结构体: 下行配网指令 (协议规范 §5.4)
struct ProvisionCommand {
    char     cmdId[32];
    char     wifi_ssid[33];
    char     wifi_password[65];
    char     wifi_encrypt[8];     // OPEN/WPA2/WPA3
    char     mqtt_broker[64];
    uint16_t mqtt_port;
    bool     mqtt_useTls;
    uint32_t timestamp;
};

#endif // CONFIG_H


claude claude-code-guide Explore general-purpose Plan statusline-setup

- claude: Catch-all for any task that doesn't fit a more specific agent. FleetView's default when no agent name is typed. (Tools: *)

- claude-code-guide: Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - Messages API for directly passing messages to Claude, Tool Runner (`client.beta.messages.tool_runner`) for running an agentic loop over your own tools, manual tool-use loops, Managed Agents for server-hosted agents with a managed sandbox, prompt caching, and general Anthropic SDK usage; (4) Claude Tag (Claude in Slack) - what it is, setting it up for a Slack workspace, `/install-slack-app`. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via SendMessage. (Tools: Glob, Grep, Read, WebFetch, WebSearch)

- Explore: Read-only search agent for broad fan-out searches — when answering means sweeping many files, directories, or naming conventions and you only need the conclusion, not the file dumps. It reads excerpts rather than whole files, so it locates code; it doesn't review or audit it. Specify search breadth: "medium" for moderate exploration, "very thorough" for multiple locations and naming conventions. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- general-purpose: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you. (Tools: *)

- Plan: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs. (Tools: All tools except Agent, Artifact, ExitPlanMode, Edit, Write, NotebookEdit)

- statusline-setup: Use this agent to configure the user's Claude Code status line setting. (Tools: Read, Edit)


The firmware was successfully uploaded. Now let me think about whether this actually fixes the issue.

After the upload, the device has been hard reset. Let me trace through the BLE flow:

1. **Cold boot**: Constructor sets `_bleServer = nullptr`, `_bleAdv = nullptr`
2. **begin()**: If no WiFi config, calls `_startPortal()`
3. **_startPortal()**: Creates AP, then calls `_startBLE()`
4. **_startBLE()**:
   - `_bleServer != nullptr` → false (cold boot), so does full BLE init
   - `BLEDevice::init("HealthMonitor")` → full init
   - Creates server, characteristics, advertising
   - BLE should be discoverable ✅

5. **User connects, sends BLE provision data**:
   - Callback enqueues JSON → `_processBLEPending()` in loop → `handleBLEProvision()`
   - `_portalStopFlag = true`
   - Next loop: `_stopPortal()` → `_stopBLE()` → only stops advertising
   - WiFi connects to provisioned network

6. **User long-presses to re-enter provisioning**:
   - `WiFi.disconnect()` → `_startPortal()`
   - Creates AP → calls `_startBLE()`
   - `_bleServer != nullptr` → true (never nulled after cold boot)
   - Calls `_bleAdv->start()` → should restart advertising ✅

Wait, I should also check what happens with `_startPortal()`. Does it check `_portalActive` first? Yes:
```cpp
if (_portalActive) return;
```
After `_stopPortal()` sets `_portalActive = false`, this should be fine.

But wait, there's still a subtle issue. When we only do `_bleAdv->start()`, the advertising data might need to be set again. Let me check the NimBLE/ESP32 BLE library behavior — does `start()` after `stop()` preserve the advertising data?

Actually, in the Arduino BLE library, once advertising data is set with `setAdvertisementData()`, it stays configured. `stop()` and `start()` just toggle the advertising on/off. So this should work.

The upload succeeded. BLE should now work on cold boot AND survive re-provisioning. Let me tell the user to test it.

But actually, I realize I should also check whether the firmware actually contains the latest changes. Let me quickly check the source code to make sure the changes are there.


烧录成功！新固件已就位，做了硬件复位。试试：

1. **冷启动** — 蓝牙 `HealthMon` 应该能搜到
2. **配网一次** — 微信小程序连上、发送 WiFi 密码
3. **长按重新配网** — 这次蓝牙应该还在，不会消失

试试看，能搜到蓝牙信号吗？


第一可以，再配就不可以了


The user confirms:
1. First time (cold boot): BLE works, can find signal ✅
2. Re-provisioning (second time): BLE doesn't work ❌

So the issue is specifically about re-entering provisioning mode. The BLE stack is kept alive (we don't deinit), but `_bleAdv->start()` doesn't work the second time after `_bleAdv->stop()`.

Let me read the current `_startBLE()`, `_stopBLE()`, and `_startPortal()` to understand the exact flow.

The issue is likely that `_bleAdv->start()` after `_bleAdv->stop()` doesn't properly restart advertising. This is a known ESP32 BLE issue - once you stop advertising, you may need to reconfigure the advertising data before starting again.

Let me read the current code to verify.


明白，第一次冷启动 BLE 正常，再次进入配网时就没了。问题出在 `_stopBLE()` 后 `_startBLE()` 的"重开广播"路径。让我看下当前代码确认。


1	/**
2	 * @file wifi_manager.cpp
3	 * @brief WiFi 配网管理器 — AP + WebServer
4	 */
5
6	#include "wifi_manager.h"
7
8	// BLE 配网回调: 小程序写入 JSON → 仅在 BLE 栈中暂存, 不直接处理
9	class BLEProvisionCB : public BLECharacteristicCallbacks {
10	    void onWrite(BLECharacteristic* ch) override {
11	        std::string val = ch->getValue();
12	        if (val.length() > 0 && WiFiMgmt::_instance) {
13	            Serial.printf("[BLE] 收到配网数据 (%d 字节)\n", val.length());
14	            // 仅暂存 → 在 loop() 里处理 (BTC_TASK 栈太小, 写 Flash 会栈溢出)
15	            WiFiMgmt::_instance->enqueueBLEJson(val.c_str());
16	        }
17	    }
18	};
19
20	WiFiMgmt* WiFiMgmt::_instance = nullptr;
21
22	WiFiMgmt::WiFiMgmt()
23	    : _server(nullptr), _dns(nullptr)
24	    , _connected(false)
25	    , _portalActive(false), _portalStopFlag(false)
26	    , _portalStartTime(0)
27	    , _lastCheckTime(0), _buttonPressTime(0), _buttonPressed(false)
28	    , _lastScanTime(0)
29	    , _provisionSuccess(false)
30	    , _ledTaskHandle(nullptr), _ledMode(0)
31	    , _bleServer(nullptr), _bleInfoChar(nullptr), _bleChar(nullptr), _bleAdv(nullptr)
32	    , _blePendingFlag(false)
33	{
34	    memset(&_config, 0, sizeof(_config));
35	    _instance = this;
36	}
37
38	WiFiMgmt::~WiFiMgmt() {
39	    _stopPortal();
40	    _instance = nullptr;
41	}
42
43	// ============================================================
44	// 初始化
45	// ============================================================
46
47	bool WiFiMgmt::begin() {
48	    Serial.println(("\n=== WiFi 配网管理器 (SmartConfig) ==="));
49
50	    pinMode(CONFIG_BUTTON_PIN, INPUT_PULLUP);
51	    pinMode(LED_WIFI_PIN, OUTPUT);
52	    digitalWrite(LED_WIFI_PIN, LOW);
53
54	    xTaskCreate(_ledTaskFunc, "WiFiLed", 1024, nullptr, 1, &_ledTaskHandle);
55
56	    // 冷启动延迟, 等 Flash 稳定
57	    delay(500);
58	    if (!LittleFS.begin(true)) {
59	        Serial.println(("[WiFi] LittleFS 挂载失败!"));
60	        return false;
61	    }
62	    Serial.println(("[WiFi] LittleFS 就绪"));
63
64	    if (!_loadConfig()) {
65	        Serial.println(("[WiFi] 使用默认配置"));
66	        strncpy(_config.mqtt_server, MQTT_DEFAULT_SERVER, sizeof(_config.mqtt_server)-1);
67	        strncpy(_config.mqtt_port,   MQTT_DEFAULT_PORT_STR, sizeof(_config.mqtt_port)-1);
68	        strncpy(_config.mqtt_user,   MQTT_DEFAULT_USER, sizeof(_config.mqtt_user)-1);
69	        strncpy(_config.mqtt_pass,   MQTT_DEFAULT_PASS, sizeof(_config.mqtt_pass)-1);
70	        strncpy(_config.device_id,   MQTT_DEFAULT_DEVICE, sizeof(_config.device_id)-1);
71	        strncpy(_config.env_note,    MQTT_DEFAULT_ENV, sizeof(_config.env_note)-1);
72	        strncpy(_config.env_type,    "bedroom",        sizeof(_config.env_type)-1);
73	        memset(_config.wifi_ssid, 0, sizeof(_config.wifi_ssid));
74	        memset(_config.wifi_pass, 0, sizeof(_config.wifi_pass));
75	    }
76
77	    WiFi.setAutoReconnect(true);
78
79	    // 有配置 → 直接连
80	    bool hasConfig = LittleFS.exists(CONFIG_FILE_DEVICE);
81	    Serial.printf("[WiFi] 配置文件: %s\n", hasConfig ? "存在" : "不存在");
82	    if (hasConfig) {
83	        _connected = _connectWiFi();
84	        Serial.printf("[WiFi] 自动连接: %s\n", _connected ? "成功" : "失败");
85	    } else {
86	        Serial.println(("[WiFi] 无配置, 跳过连接"));
87	        _connected = false;
88	    }
89
90	    if (!_connected) {
91	        _setLEDMode(2);  // 快闪 = 配网模式
92	        _startPortal();  // 只开 AP
93	    } else {
94	        _setLEDMode(3);  // 常亮
95	    }
96
97	    _lastCheckTime = millis();
98	    return _connected;
99	}
100
101	// ============================================================
102	// 主循环
103	// ============================================================
104
105	bool WiFiMgmt::loop() {
106	    // --- BLE 待处理数据 (从 loop 上下文安全处理, 不占 BTC_TASK 栈) ---
107	    _processBLEPending();
108
109	    // --- Portal 模式 ---
110	    if (_portalActive) {
111	        if (_portalStopFlag) {
112	            _stopPortal();
113	            _portalStopFlag = false;
114	            if (_pendingSSID.length() > 0) {
115	                Serial.printf("[WiFi] 尝试连接: %s\n", _pendingSSID.c_str());
116	                _connected = _tryConnect(_pendingSSID.c_str(), _pendingPass.c_str());
117	                _pendingSSID = ""; _pendingPass = "";
118	                if (_connected) {
119	                    _setLEDMode(3);
120	                    _provisionSuccess = true;  // 标记配网成功
121	                } else {
122	                    _setLEDMode(2);
123	                    _startPortal();
124	                }
125	            }
126	            return _connected;
127	        }
128
129	        _dns->processNextRequest();
130	        _server->handleClient();
131	        delay(10);
132	        return _connected;
133	    }
134
135	    // --- 正常运行 ---
136	    if (_checkButtonLongPress()) {
137	        Serial.println(("[WiFi] 长按, 进配网模式"));
138	        _setLEDMode(2);
139	        WiFi.disconnect();
140	        _startPortal();
141	        _connected = false;
142	        return false;
143	    }
144
145	    if (millis() - _lastCheckTime > WIFI_RETRY_INTERVAL) {
146	        _lastCheckTime = millis();
147	        wl_status_t s = WiFi.status();
148	        if (s != WL_CONNECTED) {
149	            if (_connected) {
150	                _connected = false;
151	                _setLEDMode(1);
152	                Serial.printf("[WiFi] 断开! status=%d RSSI=%d\n", s, WiFi.RSSI());
153	            }
154	            // 不手动 reconnect(), 让 WiFi.setAutoReconnect(true) 自动处理
155	        } else if (!_connected) {
156	            _connected = true;
157	            _setLEDMode(3);
158	            Serial.printf("[WiFi] 已连接 RSSI=%d\n", WiFi.RSSI());
159	        }
160	    }
161
162	    return _connected;
163	}
164
165	// ============================================================
166	// WiFi 连接
167	// ============================================================
168
169	bool WiFiMgmt::_connectWiFi() {
170	    if (_config.wifi_ssid[0] == '\0') {
171	        Serial.println(("[WiFi] 无已存WiFi密码"));
172	        return false;
173	    }
174	    Serial.printf("[WiFi] 连接: %s\n", _config.wifi_ssid);
175
176	    // 崩溃重启后 WiFi 模块可能残留旧状态, 彻底重置
177	    WiFi.disconnect(true);
178	    delay(200);
179	    WiFi.mode(WIFI_STA);
180	    delay(100);
181	    WiFi.begin(_config.wifi_ssid, _config.wifi_pass);
182	    int retry = 0;
183	    while (WiFi.status() != WL_CONNECTED && retry < 40) {  // 20s
184	        delay(500); Serial.print("."); yield(); retry++;
185	    }
186	    Serial.println();
187	    _connected = (WiFi.status() == WL_CONNECTED);
188	    Serial.printf("[WiFi] 连接结果: %s  status=%d\n", _connected ? "成功" : "失败", WiFi.status());
189	    if (_connected) Serial.printf("[WiFi] SSID=%s IP=%s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
190	    return _connected;
191	}
192
193	bool WiFiMgmt::_tryConnect(const char* ssid, const char* pass) {
194	    WiFi.mode(WIFI_STA);
195	    WiFi.persistent(true);   // 确保持久化 WiFi 密码到 NVS (断电不丢)
196	    WiFi.begin(ssid, pass);
197	    int retry = 0;
198	    while (WiFi.status() != WL_CONNECTED && retry < 40) {  // 20s
199	        delay(500); Serial.print("."); retry++;
200	    }
201	    bool ok = (WiFi.status() == WL_CONNECTED);
202	    Serial.printf("\n[WiFi] 连接结果: %s  status=%d\n", ok ? "成功!" : "失败", WiFi.status());
203	    return ok;
204	}
205
206	// ============================================================
207	// 配网 Portal (AP + WebServer)
208	// ============================================================
209
210	void WiFiMgmt::startConfigPortal() {
211	    _setLEDMode(2);
212	    _startPortal();
213	}
214
215	void WiFiMgmt::_startPortal() {
216	    if (_portalActive) return;
217
218	    WiFi.disconnect(true);
219	    WiFi.mode(WIFI_OFF);
220	    delay(200);
221	    WiFi.mode(WIFI_AP);
222	    WiFi.softAPConfig(IPAddress(192,168,4,1), IPAddress(192,168,4,1), IPAddress(255,255,255,0));
223	    WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASSWORD);
224	    Serial.printf("[WiFi] AP: %s  IP: 192.168.4.1\n", WIFI_AP_SSID);
225
226	    // DNS 劫持: 所有域名 → 192.168.4.1 → 触发手机强制门户弹窗
227	    _dns = new DNSServer();
228	    _dns->start(53, "*", WiFi.softAPIP());
229
230	    _server = new WebServer(80);
231	    _server->on("/",      HTTP_GET,  [this]() { _handleRoot(); });
232	    _server->on("/save",  HTTP_POST, [this]() { _handleSave(); });
233	    _server->on("/reset", HTTP_GET,  [this]() { _handleReset(); });
234	    _server->onNotFound(  [this]() { _handleRoot(); });
235	    _server->begin();
236
237	    _portalActive = true;
238	    _portalStartTime = millis();
239
240	    Serial.println(("[WiFi] 手机连 AP 后浏览器打开 http://192.168.4.1"));
241
242	    _startBLE();  // 同时启动 BLE 配网广播
243	}
244
245	void WiFiMgmt::_stopPortal() {
246	    if (!_portalActive) return;
247	    _stopBLE();
248	    if (_server) { _server->stop(); delete _server; _server = nullptr; }
249	    if (_dns)    { _dns->stop();    delete _dns;    _dns = nullptr; }
250	    WiFi.softAPdisconnect(true);
251	    _portalActive = false;
252	}
253
254	// ============================================================
255	// Web 处理器
256	// ============================================================
257
258	void WiFiMgmt::_handleRoot() {
259	    _server->send(200, "text/html; charset=utf-8", _htmlPage());
260	}
261
262	void WiFiMgmt::_handleSave() {
263	    if (_server->hasArg("s")) {
264	        _pendingSSID = _server->arg("s");
265	        _pendingPass = _server->arg("p");
266	        // 持久化 WiFi 密码到 LittleFS
267	        strncpy(_config.wifi_ssid, _pendingSSID.c_str(), sizeof(_config.wifi_ssid)-1);
268	        strncpy(_config.wifi_pass, _pendingPass.c_str(), sizeof(_config.wifi_pass)-1);
269	        if (_server->hasArg("device_id"))  strncpy(_config.device_id, _server->arg("device_id").c_str(), sizeof(_config.device_id)-1);
270	        if (_server->hasArg("env_type"))   strncpy(_config.env_type,  _server->arg("env_type").c_str(),  sizeof(_config.env_type)-1);
271	        if (_server->hasArg("env_note"))   strncpy(_config.env_note,  _server->arg("env_note").c_str(),  sizeof(_config.env_note)-1);
272	        if (_server->hasArg("mqtt_server")) strncpy(_config.mqtt_server, _server->arg("mqtt_server").c_str(), sizeof(_config.mqtt_server)-1);
273	        if (_server->hasArg("mqtt_port"))  strncpy(_config.mqtt_port,  _server->arg("mqtt_port").c_str(), sizeof(_config.mqtt_port)-1);
274	        if (_server->hasArg("mqtt_user"))  strncpy(_config.mqtt_user,  _server->arg("mqtt_user").c_str(), sizeof(_config.mqtt_user)-1);
275	        if (_server->hasArg("mqtt_pass"))  strncpy(_config.mqtt_pass,  _server->arg("mqtt_pass").c_str(), sizeof(_config.mqtt_pass)-1);
276	        _saveConfig();
277
278	        _server->send(200, "text/html; charset=utf-8",
279	            "<!DOCTYPE html><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
280	            "<title>配置完成</title>

⏳
"
285	            "
配置已保存

设备正在连接 WiFi...
等待指示灯常亮
"
286	            "
");
291
292	        _portalStopFlag = true;
293	        return;
294	    }
295	    _handleRoot();
296	}
297
298	void WiFiMgmt::_handleReset() {
299	    _server->send(200, "text/html; charset=utf-8",
300	        "<!DOCTYPE html><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>已重置</title>"
301	        "

配置已清除

设备正在重启...

");
304	    delay(500);
305	    _stopPortal();
306	    resetSettings();
307	}
308
309	// ============================================================
310	// 配网 HTML 页面 (精简版)
311	// ============================================================
312
313	void WiFiMgmt::_updateWifiScan() {
314	    _wifiScanCache = "";
315	    int n = WiFi.scanNetworks(false, true);
316	    for (int i = 0; i < n; i++) {
317	        _wifiScanCache += "<option value='" + WiFi.SSID(i) + "'>" + WiFi.SSID(i) + " (" + String(WiFi.RSSI(i)) + "dBm)</option>";
318	    }
319	    if (n == 0) _wifiScanCache = "<option value=''>未发现 WiFi</option>";
320	    WiFi.scanDelete();
321	    _lastScanTime = millis();
322	}
323
324	String WiFiMgmt::_htmlPage() {
325	    if (_wifiScanCache.length() == 0) _updateWifiScan();
326
327	    String html;
328	    html.reserve(4096);
329	    html += "<!DOCTYPE html><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'>";
330	    html += "<meta name='theme-color' content='#12a889'><title>设备配网</title>
";
351
352	    // Header + 提示
353	    html += "

📡

观照-五劳无感 设备配网

WiFi 联网与参数配置

";
354	    html += "
💡 也可用 ESP-Touch App 一键配网
";
355
356	    html += "<form method='POST' action='/save'>";
357	    html += "

📶

无线网络

选择 WiFi 或使用 SmartConfig

";
358	    html += "
WiFi 名称
📡<select name='s'>";
359	    html += _wifiScanCache;
360	    html += "</select>

";
361	    html += "
WiFi 密码
🔒<input type='password' name='p' placeholder='请输入 WiFi 密码'>

";
362
363	    html += "

⚙

设备基础信息

唯一标识与安装位置

";
364	    html += "
设备编号
#<input type='text' name='device_id' value='";
365	    html += _config.device_id;
366	    html += "'>

";
367	    html += "
环境类型
🏷<select name='env_type'>";
368	    const char* envTypes[] = {"bedroom","livingroom","bathroom","hallway","balcony","kitchen"};
369	    const char* envLabels[] = {"🛏 卧室","🛋 客厅","🛁 浴室/卫生间","🚶 走廊","🌿 阳台","🍳 厨房"};
370	    for (int i = 0; i < 6; i++) {
371	        html += "<option value='";
372	        html += envTypes[i];
373	        html += "'";
374	        if (strcmp(_config.env_type, envTypes[i]) == 0 || (strlen(_config.env_type) == 0 && i == 0))
375	            html += " selected";
376	        html += ">";
377	        html += envLabels[i];
378	        html += "</option>";
379	    }
380	    html += "</select>

";
381	    html += "
位置备注
🏠<input type='text' name='env_note' placeholder='如: 主卧 / 客厅 / 老人房' value='";
382	    html += _config.env_note;
383	    html += "'>

";
384
385	    html += "<input type='hidden' name='mqtt_server' value='";
386	    html += _config.mqtt_server;
387	    html += "'><input type='hidden' name='mqtt_port' value='";
388	    html += _config.mqtt_port;
389	    html += "'><input type='hidden' name='mqtt_user' value='";
390	    html += _config.mqtt_user;
391	    html += "'><input type='hidden' name='mqtt_pass' value='";
392	    html += _config.mqtt_pass;
393	    html += "'>";
394
395	    html += "
<button type='submit'>保存配置并重启连接</button>
</form>";
396	    html += "<button class='danger' onclick=\"if(confirm('确定恢复出厂?'))location.href='/reset'\">恢复出厂配置</button>";
397	    html += "
Health Monitor · Device Provisioning

";
398
399	    return html;
400	}
401
402	// ============================================================
403	// BLE 蓝牙配网
404	// ============================================================
405
406	void WiFiMgmt::_startBLE() {
407	    // 如果 BLE 栈已在运行, 仅重启广播 (避免 deinit→init 失败)
408	    if (_bleServer != nullptr) {
409	        if (_bleAdv) { _bleAdv->start(); }
410	        Serial.println("[BLE] ✅ 广播已重开");
411	        return;
412	    }
413
414	    BLEDevice::init("HealthMonitor");
415	    _bleServer = BLEDevice::createServer();
416
417	    BLEService* svc = _bleServer->createService("4fafc201-1fb5-459e-8fcc-c5c9c331914b");
418
419	    // 特征1: 设备信息 (READ) — 小程序连接后读取设备编号等
420	    _bleInfoChar = svc->createCharacteristic(
421	        "4fafc202-1fb5-459e-8fcc-c5c9c331914b",
422	        BLECharacteristic::PROPERTY_READ
423	    );
424	    // 设备信息 JSON (最多 512 字节)
425	    JsonDocument info;
426	    info["device_id"]   = _config.device_id;
427	    info["mqtt_server"] = _config.mqtt_server;
428	    info["mqtt_port"]   = _config.mqtt_port;
429	    info["fw_version"]  = FW_VERSION;
430	    String infoJson;
431	    serializeJson(info, infoJson);
432	    _bleInfoChar->setValue(infoJson.c_str());
433
434	    // 特征2: 配网写入 (WRITE + NOTIFY) — 小程序发配网数据, 设备回复
435	    _bleChar = svc->createCharacteristic(
436	        "4fafc203-1fb5-459e-8fcc-c5c9c331914b",
437	        BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY
438	    );
439	    _bleChar->setCallbacks(new BLEProvisionCB());
440	    _bleChar->addDescriptor(new BLE2902());  // 启用 notify
441	    _bleChar->setValue("");  // 初始为空
442
443	    svc->start();
444
445	    _bleAdv = _bleServer->getAdvertising();
446	    BLEAdvertisementData advData;
447	    advData.setCompleteServices(BLEUUID("4fafc201-1fb5-459e-8fcc-c5c9c331914b"));
448	    advData.setShortName("HealthMon");
449	    _bleAdv->setAdvertisementData(advData);
450	    _bleAdv->setScanResponse(false);  // 不需要 scan response, 全在主包
451	    _bleAdv->start();
452
453	    Serial.printf("[BLE] ✅ 配网广播: HealthMonitor  device_id=%s\n", _config.device_id);
454	}
455
456	void WiFiMgmt::_stopBLE() {
457	    if (!_bleServer) return;
458	    if (_bleAdv) { _bleAdv->stop(); }
459	    Serial.println("[BLE] 广播已暂停");
460	}
461
462	// BLE 线程安全入队 (仅暂存, 不在 BTC_TASK 回调里处理)
463	void WiFiMgmt::enqueueBLEJson(const char* json) {
464	    _blePendingJson  = json;
465	    _blePendingFlag  = true;
466	}
467
468	// 在 loop() 上下文中处理 BLE 配网数据 (安全 — 不在 BTC_TASK 栈中)
469	void WiFiMgmt::_processBLEPending() {
470	    if (!_blePendingFlag) return;
471	    _blePendingFlag = false;
472	    Serial.printf("[BLE] loop 处理配网: %s\n", _blePendingJson.c_str());
473	    handleBLEProvision(_blePendingJson.c_str());
474	    _blePendingJson = "";
475	}
476
477	void WiFiMgmt::handleBLEProvision(const char* json) {
478	    JsonDocument doc;
479	    DeserializationError err = deserializeJson(doc, json);
480	    if (err) {
481	        Serial.printf("[BLE] JSON 解析失败: %s\n", err.c_str());
482	        if (_bleChar) { _bleChar->setValue("ERR_JSON"); _bleChar->notify(); }
483	        return;
484	    }
485
486	    // 提取 WiFi 凭证
487	    const char* ssid = doc["s"] | "";
488	    const char* pass = doc["p"] | "";
489
490	    if (strlen(ssid) == 0) {
491	        Serial.println("[BLE] 未提供 WiFi SSID");
492	        if (_bleChar) { _bleChar->setValue("ERR_NO_SSID"); _bleChar->notify(); }
493	        return;
494	    }
495
496	    _pendingSSID = ssid;
497	    _pendingPass = pass;
498
499	    // 保存到 LittleFS (和 Web 配网完全相同的逻辑)
500	    strncpy(_config.wifi_ssid, ssid, sizeof(_config.wifi_ssid) - 1);
501	    strncpy(_config.wifi_pass, pass, sizeof(_config.wifi_pass) - 1);
502
503	    if (doc.containsKey("device_id"))   strncpy(_config.device_id,   doc["device_id"]   | "", sizeof(_config.device_id) - 1);
504	    if (doc.containsKey("env_note"))    strncpy(_config.env_note,    doc["env_note"]    | "", sizeof(_config.env_note) - 1);
505	    if (doc.containsKey("env_type"))    strncpy(_config.env_type,    doc["env_type"]    | "", sizeof(_config.env_type) - 1);
506	    if (doc.containsKey("mqtt_server")) strncpy(_config.mqtt_server, doc["mqtt_server"] | "", sizeof(_config.mqtt_server) - 1);
507	    if (doc.containsKey("mqtt_port"))   strncpy(_config.mqtt_port,   doc["mqtt_port"]   | "", sizeof(_config.mqtt_port) - 1);
508	    if (doc.containsKey("mqtt_user"))   strncpy(_config.mqtt_user,   doc["mqtt_user"]   | "", sizeof(_config.mqtt_user) - 1);
509	    if (doc.containsKey("mqtt_pass"))   strncpy(_config.mqtt_pass,   doc["mqtt_pass"]   | "", sizeof(_config.mqtt_pass) - 1);
510
511	    _saveConfig();
512	    _portalStopFlag = true;  // 触发延迟关闭 AP+BLE → 连接 WiFi
513
514	    Serial.printf("[BLE] ✅ 配网完成, SSID=%s\n", ssid);
515
516	    // 回复 OK
517	    if (_bleChar) { _bleChar->setValue("OK"); _bleChar->notify(); }
518	    delay(100);
519	}
520
521	// ============================================================
522	// 重置
523	// ============================================================
524
525	void WiFiMgmt::resetSettings() {
526	    Serial.println(("[WiFi] 清除所有配置..."));
527	    if (LittleFS.exists(CONFIG_FILE_WIFI))   LittleFS.remove(CONFIG_FILE_WIFI);
528	    if (LittleFS.exists(CONFIG_FILE_MQTT))   LittleFS.remove(CONFIG_FILE_MQTT);
529	    if (LittleFS.exists(CONFIG_FILE_DEVICE)) LittleFS.remove(CONFIG_FILE_DEVICE);
530	    WiFi.disconnect(true, true);
531	    for (int i = 0; i < 10; i++) { digitalWrite(LED_WIFI_PIN, !digitalRead(LED_WIFI_PIN)); delay(100); }
532	    Serial.println(("[WiFi] 正在重启..."));
533	    delay(500);
534	    ESP.restart();
535	}
536
537	// ============================================================
538	// 按钮
539	// ============================================================
540
541	bool WiFiMgmt::_checkButtonLongPress() {
542	    bool pressed = (digitalRead(CONFIG_BUTTON_PIN) == LOW);
543	    if (pressed && !_buttonPressed) {
544	        _buttonPressed = true; _buttonPressTime = millis();
545	    } else if (!pressed && _buttonPressed) {
546	        _buttonPressed = false;
547	        if (millis() - _buttonPressTime >= BUTTON_LONG_PRESS_MS) return true;
548	    }
549	    return false;
550	}
551
552	// ============================================================
553	// LittleFS
554	// ============================================================
555
556	bool WiFiMgmt::_loadConfig() {
557	    if (!LittleFS.exists(CONFIG_FILE_DEVICE)) return false;
558	    File file = LittleFS.open(CONFIG_FILE_DEVICE, "r");
559	    if (!file) return false;
560	    JsonDocument doc;
561	    DeserializationError err = deserializeJson(doc, file);
562	    file.close();
563	    if (err) return false;
564	    strncpy(_config.mqtt_server, doc["mqtt_server"] | MQTT_DEFAULT_SERVER, sizeof(_config.mqtt_server)-1);
565	    strncpy(_config.mqtt_port, doc["mqtt_port"] | MQTT_DEFAULT_PORT_STR, sizeof(_config.mqtt_port)-1);
566	    strncpy(_config.mqtt_user, doc["mqtt_user"] | MQTT_DEFAULT_USER, sizeof(_config.mqtt_user)-1);
567	    strncpy(_config.mqtt_pass, doc["mqtt_pass"] | MQTT_DEFAULT_PASS, sizeof(_config.mqtt_pass)-1);
568	    strncpy(_config.device_id, doc["device_id"] | MQTT_DEFAULT_DEVICE, sizeof(_config.device_id)-1);
569	    strncpy(_config.env_note, doc["env_note"] | MQTT_DEFAULT_ENV, sizeof(_config.env_note)-1);
570	    strncpy(_config.env_type, doc["env_type"] | "bedroom", sizeof(_config.env_type)-1);
571	    strncpy(_config.wifi_ssid, doc["wifi_ssid"] | "", sizeof(_config.wifi_ssid)-1);
572	    strncpy(_config.wifi_pass, doc["wifi_pass"] | "", sizeof(_config.wifi_pass)-1);
573	    Serial.printf("[WiFi] 配置加载, 已存WiFi: %s\n", _config.wifi_ssid[0] ? _config.wifi_ssid : "(空)");
574	    return true;
575	}
576
577	bool WiFiMgmt::_saveConfig() {
578	    JsonDocument doc;
579	    doc["mqtt_server"] = _config.mqtt_server;
580	    doc["mqtt_port"]   = _config.mqtt_port;
581	    doc["mqtt_user"]   = _config.mqtt_user;
582	    doc["mqtt_pass"]   = _config.mqtt_pass;
583	    doc["device_id"]   = _config.device_id;
584	    doc["env_note"]    = _config.env_note;
585	    doc["env_type"]    = _config.env_type;
586	    doc["wifi_ssid"]   = _config.wifi_ssid;
587	    doc["wifi_pass"]   = _config.wifi_pass;
588	    File file = LittleFS.open(CONFIG_FILE_DEVICE, "w");
589	    if (!file) return false;
590	    if (serializeJson(doc, file) == 0) { file.close(); return false; }
591	    file.close();
592	    return true;
593	}
594
595	// ============================================================
596	// Getter
597	// ============================================================
598
599	bool WiFiMgmt::isConnected() const { return _connected && (WiFi.status() == WL_CONNECTED); }
600	bool WiFiMgmt::justProvisioned() { bool v = _provisionSuccess; _provisionSuccess = false; return v; }
601	DeviceConfig WiFiMgmt::getConfig() const { return _config; }
602	const char* WiFiMgmt::getDeviceId() const { return _config.device_id; }
603	const char* WiFiMgmt::getEnvNote() const { return _config.env_note; }
604
605	// ============================================================
606	// LED
607	// ============================================================
608
609	void WiFiMgmt::_setLEDMode(uint8_t mode) { _ledMode = mode; }
610
611	void WiFiMgmt::_ledTaskFunc(void* param) {
612	    const TickType_t fastMs = pdMS_TO_TICKS(300);
613	    const TickType_t slowMs = pdMS_TO_TICKS(1000);
614	    while (true) {
615	        if (!_instance) { vTaskDelay(slowMs); continue; }
616	        uint8_t mode = _instance->_ledMode;
617	        switch (mode) {
618	        case 0: digitalWrite(LED_WIFI_PIN, LOW); vTaskDelay(slowMs); break;
619	        case 1: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(slowMs);
620	                digitalWrite(LED_WIFI_PIN, LOW);  vTaskDelay(slowMs); break;
621	        case 2: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(fastMs);
622	                digitalWrite(LED_WIFI_PIN, LOW);  vTaskDelay(fastMs); break;
623	        case 3: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(slowMs); break;
624	        }
625	    }
626	}
627

/**
 * @file wifi_manager.cpp
 * @brief WiFi 配网管理器 — AP + WebServer
 */

#include "wifi_manager.h"

// BLE 配网回调: 小程序写入 JSON → 仅在 BLE 栈中暂存, 不直接处理
class BLEProvisionCB : public BLECharacteristicCallbacks {
    void onWrite(BLECharacteristic* ch) override {
        std::string val = ch->getValue();
        if (val.length() > 0 && WiFiMgmt::_instance) {
            Serial.printf("[BLE] 收到配网数据 (%d 字节)\n", val.length());
            // 仅暂存 → 在 loop() 里处理 (BTC_TASK 栈太小, 写 Flash 会栈溢出)
            WiFiMgmt::_instance->enqueueBLEJson(val.c_str());
        }
    }
};

WiFiMgmt* WiFiMgmt::_instance = nullptr;

WiFiMgmt::WiFiMgmt()
    : _server(nullptr), _dns(nullptr)
    , _connected(false)
    , _portalActive(false), _portalStopFlag(false)
    , _portalStartTime(0)
    , _lastCheckTime(0), _buttonPressTime(0), _buttonPressed(false)
    , _lastScanTime(0)
    , _provisionSuccess(false)
    , _ledTaskHandle(nullptr), _ledMode(0)
    , _bleServer(nullptr), _bleInfoChar(nullptr), _bleChar(nullptr), _bleAdv(nullptr)
    , _blePendingFlag(false)
{
    memset(&_config, 0, sizeof(_config));
    _instance = this;
}

WiFiMgmt::~WiFiMgmt() {
    _stopPortal();
    _instance = nullptr;
}

// ============================================================
// 初始化
// ============================================================

bool WiFiMgmt::begin() {
    Serial.println(("\n=== WiFi 配网管理器 (SmartConfig) ==="));

    pinMode(CONFIG_BUTTON_PIN, INPUT_PULLUP);
    pinMode(LED_WIFI_PIN, OUTPUT);
    digitalWrite(LED_WIFI_PIN, LOW);

    xTaskCreate(_ledTaskFunc, "WiFiLed", 1024, nullptr, 1, &_ledTaskHandle);

    // 冷启动延迟, 等 Flash 稳定
    delay(500);
    if (!LittleFS.begin(true)) {
        Serial.println(("[WiFi] LittleFS 挂载失败!"));
        return false;
    }
    Serial.println(("[WiFi] LittleFS 就绪"));

    if (!_loadConfig()) {
        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));
    }

    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) {
        _setLEDMode(2);  // 快闪 = 配网模式
        _startPortal();  // 只开 AP
    } else {
        _setLEDMode(3);  // 常亮
    }

    _lastCheckTime = millis();
    return _connected;
}

// ============================================================
// 主循环
// ============================================================

bool WiFiMgmt::loop() {
    // --- BLE 待处理数据 (从 loop 上下文安全处理, 不占 BTC_TASK 栈) ---
    _processBLEPending();

    // --- 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(("[WiFi] 长按, 进配网模式"));
        _setLEDMode(2);
        WiFi.disconnect();
        _startPortal();
        _connected = false;
        return false;
    }

    if (millis() - _lastCheckTime > WIFI_RETRY_INTERVAL) {
        _lastCheckTime = millis();
        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);
            Serial.printf("[WiFi] 已连接 RSSI=%d\n", WiFi.RSSI());
        }
    }

    return _connected;
}

// ============================================================
// WiFi 连接
// ============================================================

bool WiFiMgmt::_connectWiFi() {
    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() {
    _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_type"))   strncpy(_config.env_type,  _server->arg("env_type").c_str(),  sizeof(_config.env_type)-1);
        if (_server->hasArg("env_note"))   strncpy(_config.env_note,  _server->arg("env_note").c_str(),  sizeof(_config.env_note)-1);
        if (_server->hasArg("mqtt_server")) strncpy(_config.mqtt_server, _server->arg("mqtt_server").c_str(), sizeof(_config.mqtt_server)-1);
        if (_server->hasArg("mqtt_port"))  strncpy(_config.mqtt_port,  _server->arg("mqtt_port").c_str(), sizeof(_config.mqtt_port)-1);
        if (_server->hasArg("mqtt_user"))  strncpy(_config.mqtt_user,  _server->arg("mqtt_user").c_str(), sizeof(_config.mqtt_user)-1);
        if (_server->hasArg("mqtt_pass"))  strncpy(_config.mqtt_pass,  _server->arg("mqtt_pass").c_str(), sizeof(_config.mqtt_pass)-1);
        _saveConfig();

        _server->send(200, "text/html; charset=utf-8",
            "<!DOCTYPE html><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
            "<title>配置完成</title>

⏳
"
            "
配置已保存

设备正在连接 WiFi...
等待指示灯常亮
"
            "
");

        _portalStopFlag = true;
        return;
    }
    _handleRoot();
}

void WiFiMgmt::_handleReset() {
    _server->send(200, "text/html; charset=utf-8",
        "<!DOCTYPE html><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>已重置</title>"
        "

配置已清除

设备正在重启...

");
    delay(500);
    _stopPortal();
    resetSettings();
}

// ============================================================
// 配网 HTML 页面 (精简版)
// ============================================================

void WiFiMgmt::_updateWifiScan() {
    _wifiScanCache = "";
    int n = WiFi.scanNetworks(false, true);
    for (int i = 0; i < n; i++) {
        _wifiScanCache += "<option value='" + WiFi.SSID(i) + "'>" + WiFi.SSID(i) + " (" + String(WiFi.RSSI(i)) + "dBm)</option>";
    }
    if (n == 0) _wifiScanCache = "<option value=''>未发现 WiFi</option>";
    WiFi.scanDelete();
    _lastScanTime = millis();
}

String WiFiMgmt::_htmlPage() {
    if (_wifiScanCache.length() == 0) _updateWifiScan();

    String html;
    html.reserve(4096);
    html += "<!DOCTYPE html><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>
";

    // Header + 提示
    html += "

📡

观照-五劳无感 设备配网

WiFi 联网与参数配置

";
    html += "
💡 也可用 ESP-Touch App 一键配网
";

    html += "<form method='POST' action='/save'>";
    html += "

📶

无线网络

选择 WiFi 或使用 SmartConfig

";
    html += "
WiFi 名称
📡<select name='s'>";
    html += _wifiScanCache;
    html += "</select>

";
    html += "
WiFi 密码
🔒<input type='password' name='p' placeholder='请输入 WiFi 密码'>

";

    html += "

⚙

设备基础信息

唯一标识与安装位置

";
    html += "
设备编号
#<input type='text' name='device_id' value='";
    html += _config.device_id;
    html += "'>

";
    html += "
环境类型
🏷<select name='env_type'>";
    const char* envTypes[] = {"bedroom","livingroom","bathroom","hallway","balcony","kitchen"};
    const char* envLabels[] = {"🛏 卧室","🛋 客厅","🛁 浴室/卫生间","🚶 走廊","🌿 阳台","🍳 厨房"};
    for (int i = 0; i < 6; i++) {
        html += "<option value='";
        html += envTypes[i];
        html += "'";
        if (strcmp(_config.env_type, envTypes[i]) == 0 || (strlen(_config.env_type) == 0 && i == 0))
            html += " selected";
        html += ">";
        html += envLabels[i];
        html += "</option>";
    }
    html += "</select>

";
    html += "
位置备注
🏠<input type='text' name='env_note' placeholder='如: 主卧 / 客厅 / 老人房' value='";
    html += _config.env_note;
    html += "'>

";

    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 += "
<button type='submit'>保存配置并重启连接</button>
</form>";
    html += "<button class='danger' onclick=\"if(confirm('确定恢复出厂?'))location.href='/reset'\">恢复出厂配置</button>";
    html += "
Health Monitor · Device Provisioning

";

    return html;
}

// ============================================================
// BLE 蓝牙配网
// ============================================================

void WiFiMgmt::_startBLE() {
    // 如果 BLE 栈已在运行, 仅重启广播 (避免 deinit→init 失败)
    if (_bleServer != nullptr) {
        if (_bleAdv) { _bleAdv->start(); }
        Serial.println("[BLE] ✅ 广播已重开");
        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();
    BLEAdvertisementData advData;
    advData.setCompleteServices(BLEUUID("4fafc201-1fb5-459e-8fcc-c5c9c331914b"));
    advData.setShortName("HealthMon");
    _bleAdv->setAdvertisementData(advData);
    _bleAdv->setScanResponse(false);  // 不需要 scan response, 全在主包
    _bleAdv->start();

    Serial.printf("[BLE] ✅ 配网广播: HealthMonitor  device_id=%s\n", _config.device_id);
}

void WiFiMgmt::_stopBLE() {
    if (!_bleServer) return;
    if (_bleAdv) { _bleAdv->stop(); }
    Serial.println("[BLE] 广播已暂停");
}

// BLE 线程安全入队 (仅暂存, 不在 BTC_TASK 回调里处理)
void WiFiMgmt::enqueueBLEJson(const char* json) {
    _blePendingJson  = json;
    _blePendingFlag  = true;
}

// 在 loop() 上下文中处理 BLE 配网数据 (安全 — 不在 BTC_TASK 栈中)
void WiFiMgmt::_processBLEPending() {
    if (!_blePendingFlag) return;
    _blePendingFlag = false;
    Serial.printf("[BLE] loop 处理配网: %s\n", _blePendingJson.c_str());
    handleBLEProvision(_blePendingJson.c_str());
    _blePendingJson = "";
}

void WiFiMgmt::handleBLEProvision(const char* json) {
    JsonDocument doc;
    DeserializationError err = deserializeJson(doc, json);
    if (err) {
        Serial.printf("[BLE] JSON 解析失败: %s\n", err.c_str());
        if (_bleChar) { _bleChar->setValue("ERR_JSON"); _bleChar->notify(); }
        return;
    }

    // 提取 WiFi 凭证
    const char* ssid = doc["s"] | "";
    const char* pass = doc["p"] | "";

    if (strlen(ssid) == 0) {
        Serial.println("[BLE] 未提供 WiFi SSID");
        if (_bleChar) { _bleChar->setValue("ERR_NO_SSID"); _bleChar->notify(); }
        return;
    }

    _pendingSSID = ssid;
    _pendingPass = pass;

    // 保存到 LittleFS (和 Web 配网完全相同的逻辑)
    strncpy(_config.wifi_ssid, ssid, sizeof(_config.wifi_ssid) - 1);
    strncpy(_config.wifi_pass, pass, sizeof(_config.wifi_pass) - 1);

    if (doc.containsKey("device_id"))   strncpy(_config.device_id,   doc["device_id"]   | "", sizeof(_config.device_id) - 1);
    if (doc.containsKey("env_note"))    strncpy(_config.env_note,    doc["env_note"]    | "", sizeof(_config.env_note) - 1);
    if (doc.containsKey("env_type"))    strncpy(_config.env_type,    doc["env_type"]    | "", sizeof(_config.env_type) - 1);
    if (doc.containsKey("mqtt_server")) strncpy(_config.mqtt_server, doc["mqtt_server"] | "", sizeof(_config.mqtt_server) - 1);
    if (doc.containsKey("mqtt_port"))   strncpy(_config.mqtt_port,   doc["mqtt_port"]   | "", sizeof(_config.mqtt_port) - 1);
    if (doc.containsKey("mqtt_user"))   strncpy(_config.mqtt_user,   doc["mqtt_user"]   | "", sizeof(_config.mqtt_user) - 1);
    if (doc.containsKey("mqtt_pass"))   strncpy(_config.mqtt_pass,   doc["mqtt_pass"]   | "", sizeof(_config.mqtt_pass) - 1);

    _saveConfig();
    _portalStopFlag = true;  // 触发延迟关闭 AP+BLE → 连接 WiFi

    Serial.printf("[BLE] ✅ 配网完成, SSID=%s\n", ssid);

    // 回复 OK
    if (_bleChar) { _bleChar->setValue("OK"); _bleChar->notify(); }
    delay(100);
}

// ============================================================
// 重置
// ============================================================

void WiFiMgmt::resetSettings() {
    Serial.println(("[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();
    } else if (!pressed && _buttonPressed) {
        _buttonPressed = false;
        if (millis() - _buttonPressTime >= BUTTON_LONG_PRESS_MS) return true;
    }
    return false;
}

// ============================================================
// LittleFS
// ============================================================

bool WiFiMgmt::_loadConfig() {
    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) 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;
}

bool WiFiMgmt::_saveConfig() {
    JsonDocument doc;
    doc["mqtt_server"] = _config.mqtt_server;
    doc["mqtt_port"]   = _config.mqtt_port;
    doc["mqtt_user"]   = _config.mqtt_user;
    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) return false;
    if (serializeJson(doc, file) == 0) { file.close(); return false; }
    file.close();
    return true;
}

// ============================================================
// Getter
// ============================================================

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; }

// ============================================================
// LED
// ============================================================

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);
    while (true) {
        if (!_instance) { vTaskDelay(slowMs); continue; }
        uint8_t mode = _instance->_ledMode;
        switch (mode) {
        case 0: digitalWrite(LED_WIFI_PIN, LOW); vTaskDelay(slowMs); break;
        case 1: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(slowMs);
                digitalWrite(LED_WIFI_PIN, LOW);  vTaskDelay(slowMs); break;
        case 2: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(fastMs);
                digitalWrite(LED_WIFI_PIN, LOW);  vTaskDelay(fastMs); break;
        case 3: digitalWrite(LED_WIFI_PIN, HIGH); vTaskDelay(slowMs); break;
        }
    }
}


Now I see the issue. Let me trace the second provisioning flow:

1. Cold boot → `_startBLE()` → `_bleServer` is null → full BLE init → BLE works ✅
2. Provisioning succeeds → `_stopPortal()` → `_stopBLE()` → `_bleAdv->stop()` → BLE stack stays alive
3. User long-presses → `_startPortal()` → calls `_startBLE()`
4. `_startBLE()` → `_bleServer != nullptr` (true!) → `_bleAdv->start()`
5. But here's the problem: after `_stopBLE()` called `_bleAdv->stop()`, the advertising was stopped. When we call `_bleAdv->start()` again, the advertising data that was configured during init is still there... OR IS IT?

Actually, the issue might be more subtle. Let me think about ESP32 BLE behavior:

When `BLEDevice::init()` is called, it takes over the BLE radio. But in `_startPortal()`, the sequence is:
1. `WiFi.disconnect(true)`
2. `WiFi.mode(WIFI_OFF)` — **This turns off BOTH WiFi and BLE!**
3. `delay(200)`
4. `WiFi.mode(WIFI_AP)` — This turns on WiFi AP

When `WiFi.mode(WIFI_OFF)` is called, it shuts down the entire radio, including the BLE stack. So after `WiFi.mode(WIFI_OFF)`, the BLE stack is GONE — `_bleServer` points to freed memory, `_bleAdv` points to freed memory, etc.

Then when `_startBLE()` is called, it checks `if (_bleServer != nullptr)` — but `_bleServer` still holds the old pointer value! It wasn't set to null. The BLE stack was destroyed by `WiFi.mode(WIFI_OFF)`, but our pointers weren't cleared.

So the fix is: `_stopBLE()` needs to null out the pointers when the BLE stack is killed by WiFi mode changes. OR, `_startBLE()` needs to properly detect that BLE was killed by WiFi changes.

Actually wait — the first time (cold boot without WiFi config), `_startPortal()` is called FIRST, before BLE was ever initialized. So:
1. `WiFi.mode(WIFI_OFF)` → `WiFi.mode(WIFI_AP)` → `_startBLE()` → `BLEDevice::init(...)` → works fine

Second time (long press re-provision):
1. WiFi is in STA mode (connected)
2. `WiFi.disconnect()` in loop()
3. `_startPortal()`:
   - `WiFi.disconnect(true)`
   - `WiFi.mode(WIFI_OFF)` — **kills both WiFi and BLE radio, but _bleServer pointer is NOT nulled**
   - `delay(200)`
   - `WiFi.mode(WIFI_AP)` — turns on WiFi AP only
   - `_startBLE()` checks `_bleServer != nullptr` → TRUE (stale pointer!) → tries `_bleAdv->start()` on destroyed BLE stack → doesn't work!

That's the bug! `WiFi.mode(WIFI_OFF)` destroys the BLE stack but we don't clear our pointers.

The fix options:
1. In `_stopPortal()` / `_stopBLE()`, null the BLE pointers
2. OR, in `_startBLE()`, instead of checking `_bleServer`, check if `BLEDevice::getInitialized()` returns true (if such API exists)
3. OR, in `_startBLE()`, do a full re-init if BLE was stopped

The cleanest fix is approach 1: null the pointers when stopping BLE. But we also need to handle the case where WiFi mode changed behind our back (which it will since `_startPortal()` calls `WiFi.mode(WIFI_OFF)` before calling `_startBLE()`).

Wait, actually the call order in `_startPortal()` is:
1. `WiFi.mode(WIFI_OFF)` — kills BLE
2. delays
3. `WiFi.mode(WIFI_AP)`
4. `_startBLE()` — but _bleServer still has stale pointer

So the fix should be: when `_stopBLE()` is called, null out `_bleServer`, `_bleInfoChar`, `_bleChar`, `_bleAdv`. But wait, `_stopBLE()` is called from `_stopPortal()`, and `_stopPortal()` is called before `_startPortal()` is called again? No — `_stopPortal()` is called when provisioning completes, not before re-entering.

Let me re-trace:
1. First provisioning: `_startPortal()` → BLE works → provisioning completes → `_stopPortal()` → `_stopBLE()` → `_bleAdv->stop()` (not nulled)
2. WiFi connects (STA mode)
3. Long press: `WiFi.disconnect()` → `_startPortal()`:
   - `WiFi.mode(WIFI_OFF)` — **this is what kills BLE**
   - `_startBLE()` → `_bleServer != nullptr` → `_bleAdv->start()` → **FAIL! BLE was killed by WiFi.mode(WIFI_OFF)**

So there are two separate issues:
1. `_startPortal()` calls `WiFi.mode(WIFI_OFF)` which silently kills the BLE stack without nulling our pointers
2. `_startBLE()` checks for non-null `_bleServer` which is now a stale pointer

The fix: in `_startPortal()`, call `_stopBLE()` BEFORE `WiFi.mode(WIFI_OFF)`, and in `_stopBLE()`, null the pointers. Or simpler: in `_startBLE()`, don't trust `_bleServer != nullptr` — instead, always do full init. But that would leak if BLE is actually still running...

Actually, the simplest and most robust fix: In `_startBLE()`, always check if BLE is actually running, and if not, do a full init. One way is to check `BLEDevice::getInitialized()`.

Actually, looking at the ESP32 BLE Arduino library, I'm not sure `getInitialized()` exists. Let me think of another approach.

The most robust approach:
1. In `_stopBLE()`, set `_bleServer = nullptr`, `_bleAdv = nullptr`, `_bleInfoChar = nullptr`, `_bleChar = nullptr`
2. In `_startPortal()`, add `_stopBLE()` call BEFORE `WiFi.mode(WIFI_OFF)` (or just null the pointers after WiFi.mode(WIFI_OFF))

Actually wait, that still won't work because `_stopPortal()` was already called (which calls `_stopBLE()`), and then the BLE stack is alive but advertising stopped. Then `_startPortal()` is called again, and `WiFi.mode(WIFI_OFF)` kills BLE silently.

Hmm, actually, let me reconsider. After `_stopPortal()` is called (provisioning done), `_stopBLE()` stops advertising but keeps the stack alive. Then WiFi connects in STA mode. BLE and WiFi STA coexist on ESP32 (they share the radio via time-division). So the BLE stack IS still alive at this point.

Then long press: `WiFi.disconnect()` → `_startPortal()` → `WiFi.mode(WIFI_OFF)` kills BLE. Then `_startBLE()` checks stale pointer.

The fix is:
- In `_startPortal()`, after `WiFi.mode(WIFI_OFF)`, set `_bleServer = nullptr; _bleAdv = nullptr; _bleInfoChar = nullptr; _bleChar = nullptr;`
- OR, in `_startBLE()`, check if BLE is actually initialized, not just if pointers are non-null
- OR, in `_stopBLE()`, set all pointers to null, AND call `_stopBLE()` before `WiFi.mode(WIFI_OFF)` in `_startPortal()`

The cleanest approach for `_startBLE()`: Instead of checking `_bleServer != nullptr`, always do a full re-init. But that would leak BLE resources on the "restart advertising" path...

Actually wait, the "restart advertising" path fundamentally doesn't make much sense. The only time we'd want to restart advertising without full init is when BLE stack is alive but advertising was stopped. But in practice, the flow is:
- `_stopPortal()` → `_stopBLE()` stops advertising
- Then when re-entering portal, `_startPortal()` calls `WiFi.mode(WIFI_OFF)` which kills BLE anyway

So the "fast path" in `_startBLE()` is actually never useful — by the time we call `_startBLE`, WiFi mode already killed BLE.

SIMPLEST FIX: In `_startBLE()`, don't do the "restart" path. Always do full init. Deinit first if needed.

```cpp
void WiFiMgmt::_startBLE() {
    // Always clean up first (WiFi.mode changes may have killed BLE silently)
    if (_bleServer != nullptr) {
        // Attempt to clean up what remains
        _bleAdv = nullptr;
        _bleInfoChar = nullptr;
        _bleChar = nullptr;
        _bleServer = nullptr;
    }

    BLEDevice::init("HealthMonitor");
    // ... rest of init
}
```

But wait, this has the original problem: `BLEDevice::init()` can fail if BLE was already initialized. We need to deinit first. But the whole reason we avoided deinit was that `BLEDevice::deinit()` followed by `BLEDevice::init()` doesn't work without reboot on ESP32...

Hmm, that was the issue from the previous fix. But now the situation is different: `WiFi.mode(WIFI_OFF)` has already killed the BLE stack. So `BLEDevice::init()` should work fresh.

Actually, let me re-read the ESP32 BLE behavior. `WiFi.mode(WIFI_OFF)` turns off the radio. The BLE software stack objects (`_bleServer`, `_bleAdv`, etc.) are still allocated in memory but point to freed/invalid BLE hardware resources. The `BLEDevice` singleton still exists but its internal state says "initialized".

So `BLEDevice::init()` would fail because it thinks it's already initialized.

I think the best approach is:
1. In `_startPortal()`, after `WiFi.mode(WIFI_OFF)`, release BLE resources properly
2. In `_startBLE()`, always do full init from scratch

But we need to handle the fact that `BLEDevice` might think it's initialized. Let me check if there's a way around this...

Actually, on ESP32 Arduino, `BLEDevice::deinit(true)` releases BLE resources. And the issue before was that `deinit()` followed by `init()` doesn't reinitialize properly. But maybe the issue was specifically that the old code called deinit from `_stopBLE` every time, when the BLE stack was still running alongside WiFi STA.

Let me try a different approach: In `_startPortal()`, call `BLEDevice::deinit(true)` after `WiFi.mode(WIFI_OFF)` and clear our pointers. Then in `_startBLE()`, always do full init.

Wait, but `BLEDevice::deinit(true)` needs to be called BEFORE `WiFi.mode(WIFI_OFF)` ideally, because after WiFi mode change, the BLE hardware is already freed.

OK let me think about this more carefully. I think the cleanest approach is:

**In `_startPortal()`:**
1. Before `WiFi.mode(WIFI_OFF)`, fully deinit BLE (if server exists):
```cpp
if (_bleServer) {
    _bleAdv->stop();
    _bleServer->removeService(...); // or just deinit
    BLEDevice::deinit(true);
    _bleServer = nullptr;
    _bleAdv = nullptr;
    _bleInfoChar = nullptr;
    _bleChar = nullptr;
}
```
2. Then `WiFi.mode(WIFI_OFF)` → `WiFi.mode(WIFI_AP)`
3. Then `_startBLE()` does full fresh init

**In `_startBLE()`:**
- Remove the "restart" path, always do full init
- Check if already initialized and if so, skip or deinit first

**In `_stopBLE()`:**
- Just stop advertising, set pointers to null (no deinit)
- Actually, it might make more sense to just keep `_stopBLE()` as is but add nulling

Actually, I think the simplest approach that works:

1. Remove the "restart advertising" path from `_startBLE()` — always do full init
2. In `_startPortal()`, properly deinit BLE before `WiFi.mode(WIFI_OFF)` changes kill it
3. Null all BLE pointers after deinit

But there's a risk with deinit+init cycle... Let me just test with a simple fix first.

ALTERNATIVE SIMPLER FIX: Don't use `WiFi.mode(WIFI_OFF)` in `_startPortal()`. Instead, just switch directly to AP mode. ESP32 can handle mode switches gracefully.

Actually no, looking at the code, the `WiFi.mode(WIFI_OFF)` is there intentionally to reset the WiFi state before starting AP mode. Removing it might cause issues.

OK, let me just go with the pragmatic fix. The real issue is:

1. `_startPortal()` kills BLE via `WiFi.mode(WIFI_OFF)`
2. `_startBLE()` thinks BLE is still alive (stale pointers)

The fix: In `_startPortal()`, after `WiFi.mode(WIFI_OFF)` kills everything, clear our BLE pointers. Then in `_startBLE()`, the `_bleServer == nullptr` check will correctly trigger full init.

```cpp
void WiFiMgmt::_startPortal() {
    if (_portalActive) return;

    WiFi.disconnect(true);
    WiFi.mode(WIFI_OFF);  // This kills BLE too
    // Clear BLE pointers since WiFi.mode(WIFI_OFF) destroyed the BLE stack
    _bleServer = nullptr;
    _bleAdv = nullptr;
    _bleInfoChar = nullptr;
    _bleChar = nullptr;
    delay(200);
    WiFi.mode(WIFI_AP);
    // ... rest
}
```

And in `_stopBLE()`:
```cpp
void WiFiMgmt::_stopBLE() {
    if (!_bleServer) return;
    if (_bleAdv) { _bleAdv->stop(); }
    _bleServer = nullptr;
    _bleAdv = nullptr;
    _bleInfoChar = nullptr;
    _bleChar = nullptr;
    Serial.println("[BLE] 广播已暂停");
}
```

This way, whether BLE is stopped via `_stopBLE()` or killed by `WiFi.mode(WIFI_OFF)`, the pointers are nulled and `_startBLE()` will do a full init.

But wait — can `BLEDevice::init()` be called again after `WiFi.mode(WIFI_OFF)` killed BLE? The `BLEDevice` object is a singleton and might still think it's initialized...

Let me check: in the original cold boot flow, `_startPortal()` is called from `begin()`. Before that, no BLE was initialized. So `WiFi.mode(WIFI_OFF)` → `WiFi.mode(WIFI_AP)` → `_startBLE()` → `BLEDevice::init(...)` — this works because BLE was never initialized before.

On second provisioning: `_startPortal()` → `WiFi.mode(WIFI_OFF)` kills BLE → null pointers → `_startBLE()` → `BLEDevice::init("HealthMonitor")`.

The question is: does `BLEDevice::init()` work correctly after the BLE stack was silently killed by WiFi mode change?

Looking at ESP32 Arduino BLE source: `BLEDevice::init()` calls `esp_bt_controller_mem_release()` and then `esp_bt_controller_init()` and `esp_bluedroid_init()`. When WiFi mode changes to WIFI_OFF, these BT resources are released by the WiFi driver. So `BLEDevice`'s internal flag `initialized` might still be true, but the underlying BT controller is deinitialized.

`BLEDevice::init()` checks `if (initialized) return ESP_OK;` — so it would return immediately without reinitializing! That's the problem.

So I need to handle this by either:
1. Calling `BLEDevice::deinit(true)` BEFORE `WiFi.mode(WIFI_OFF)` to properly tear down BLE
2. OR after `WiFi.mode(WIFI_OFF)`, somehow reset `BLEDevice`'s internal state

Approach 1 is cleaner. Let me add a proper BLE cleanup in `_startPortal()`:

```cpp
void WiFiMgmt::_startPortal() {
    if (_portalActive) return;

    // Properly tear down BLE before WiFi mode change kills the radio
    if (_bleServer != nullptr) {
        if (_bleAdv) _bleAdv->stop();
        _bleServer->removeService(/* service */); // Actually we need the service pointer...
        // Simpler: just deinit
        BLEDevice::deinit(true);
        _bleServer = nullptr;
        _bleAdv = nullptr;
        _bleInfoChar = nullptr;
        _bleChar = nullptr;
    }

    WiFi.disconnect(true);
    WiFi.mode(WIFI_OFF);
    delay(200);
    WiFi.mode(WIFI_AP);
    // ...
}
```

Hmm, but the old issue was that `BLEDevice::deinit()` followed by `BLEDevice::init()` fails... Let me re-examine.

The old issue was: `BLEDevice::deinit(false)` → then later `BLEDevice::init()` → memory issues because deinit(false) doesn't fully release, and reinit overwrites.

But `BLEDevice::deinit(true)` should fully release all resources, including freeing memory. Then `BLEDevice::init()` should work cleanly... in theory.

Actually, let me look at this from a different angle. The ESP32 BLE Arduino library has known issues with deinit/init cycles. The most reliable approach is:

**Don't use `WiFi.mode(WIFI_OFF)` for re-provisioning.** Instead, just switch directly from STA to AP mode:

```cpp
void WiFiMgmt::_startPortal() {
    if (_portalActive) return;

    // Don't use WIFI_OFF — it kills BLE silently
    // Just disconnect and switch
    WiFi.disconnect(true);
    delay(200);
    WiFi.mode(WIFI_AP);
    // ... rest the same
}
```

`WiFi.mode(WIFI_AP)` will switch from STA to AP mode without turning the radio off. The BLE stack should survive this.

But wait, this might have its own issues — without WIFI_OFF to reset, the AP might not start properly.

Actually, for ESP32, switching directly with `WiFi.mode(WIFI_AP)` should work. The WiFi driver handles the mode switch internally.

But there's another subtlety: BLE and WiFi AP can coexist on ESP32 (they share the radio). So even without the `WiFi.mode(WIFI_OFF)` kill, `_startBLE()` should work because BLE was never killed.

Let me re-examine: after `_stopPortal()`:
1. `_stopBLE()` → `_bleAdv->stop()` — advertising stops, but BLE stack alive, `_bleServer` valid
2. WiFi connects in STA mode

Then long press:
1. `WiFi.disconnect()` from STA
2. `_startPortal()` → `WiFi.mode(WIFI_OFF)` → **BLE killed**
3. `_startBLE()` → stale pointer check

If I remove `WiFi.mode(WIFI_OFF)`, the flow would be:
1. `WiFi.disconnect()` from STA
2. `_startPortal()` → `WiFi.mode(WIFI_AP)` → BLE stack survives
3. `_startBLE()` → `_bleServer != nullptr` → `_bleAdv->start()` → **should work!**

So the fix might be as simple as removing `WiFi.mode(WIFI_OFF)` from `_startPortal()`. Let me also check if the `WiFi.disconnect(true)` and `WiFi.mode(WIFI_AP)` sequence works without the OFF reset.

Actually, `WiFi.mode(WIFI_AP)` internally handles the transition. And ESP32 can run BLE + WiFi AP simultaneously (they time-share the radio).

Let me go with this approach:
1. In `_startPortal()`, remove `WiFi.mode(WIFI_OFF)` — just go directly to AP mode
2. In `_stopBLE()`, keep the same (just stop advertising, no deinit)
3. In `_startBLE()`, keep the restart path — it will work now because BLE was never killed

This is actually the simplest fix! But let me also null pointers in `_stopBLE()` just to be safe. Actually no, the restart path in `_startBLE()` depends on `_bleServer != nullptr`, so don't null it.

Wait, but `WiFi.mode(WIFI_OFF)` was originally there for a reason — to ensure clean WiFi state transition. Let me think about what happens without it...

When STA is connected and we want to start AP:
- `WiFi.disconnect(true)` — disconnect from AP, clear saved params
- `WiFi.mode(WIFI_AP)` — this should internally handle: stop STA, configure AP mode, start softAP

This should be fine. The ESP32 Arduino WiFi library handles mode switches internally.

OK my plan:
1. Remove `WiFi.mode(WIFI_OFF)` and `delay(200)` from `_startPortal()` — just do `WiFi.mode(WIFI_AP)` directly after disconnect
2. This preserves BLE stack across mode switches
3. The restart path in `_startBLE()` (`_bleAdv->start()`) will work correctly

Let me write it.

Actually, I also want to make sure `_stopBLE()` nulls pointers so that even if BLE is somehow killed, the next `_startBLE()` will do full init. But the restart path won't work if BLE was killed...

Hmm, I think the safest approach is a combination:
1. Remove `WiFi.mode(WIFI_OFF)` from `_startPortal()` (prevents silent BLE kill)
2. Keep the restart path in `_startBLE()`
3. Null BLE pointers after deinit to be safe

But really, point 1 alone should fix the issue. Let me just do that.

Actually, wait. Even without `WiFi.mode(WIFI_OFF)`, there's a potential issue: `WiFi.mode(WIFI_AP)` call when WiFi was previously in STA mode might implicitly change the BLE coexistence settings. But ESP32's BT/WiFi coexistence is handled by the PHY layer and should be transparent.

Let me just go with removing `WiFi.mode(WIFI_OFF)`. This is the minimal fix.

But actually, let me also consider: what if the user hasn't burned the firmware? Oh wait, I just burned it. The user tested and said "第一可以，再配就不可以了" — so the firmware I just burned still has this issue.

Let me also check: is there potentially another issue besides WiFi.mode(WIFI_OFF)? Let me think about the `_bleAdv->stop()` / `_bleAdv->start()` cycle more carefully.

When `_bleAdv->stop()` is called, it calls `esp_ble_gap_stop_advertising()`. After that, `_bleAdv->start()` should call `esp_ble_gap_start_advertising()` with the previously set parameters. The advertising data was set with `setAdvertisementData()` and should still be cached in the `_bleAdv` object.

But is there a timing issue? After `_bleAdv->stop()`, does the BLE stack need time to fully stop before starting again? There might be a race condition.

The ESP-IDF docs say `esp_ble_gap_stop_advertising()` is asynchronous — it sends a command to the BLE controller and returns immediately. The actual stop happens later. If you call `esp_ble_gap_start_advertising()` before the stop completes, it might fail silently.

So even with the `WiFi.mode(WIFI_OFF)` issue fixed, there could be a timing issue with stop/start. But this would only be an issue if `_stopBLE()` (which calls `_bleAdv->stop()`) is called shortly before `_startBLE()` (which calls `_bleAdv->start()`). Let's check the timing.

In the re-provisioning flow:
1. Provisioning completes → `_stopPortal()` → `_stopBLE()` → `_bleAdv->stop()`
2. WiFi connects (STA mode) — this takes several seconds
3. User long-presses (at least 3 seconds later)
4. `_startPortal()` → `_startBLE()` → `_bleAdv->start()`

So there's at least 3+ seconds between stop and start. That should be plenty of time for the async stop to complete. So timing shouldn't be an issue.

Conclusion: the main issue is `WiFi.mode(WIFI_OFF)` silently killing BLE. Let me fix that.

Now, I'm in plan mode. Let me write the plan.


找到根因了！问题在 `_startPortal()` 第 219 行的 `WiFi.mode(WIFI_OFF)`：

**第二次进配网时的流程：**
1. 长按 → `WiFi.disconnect()` → `_startPortal()`
2. `WiFi.mode(WIFI_OFF)` → **这把 BLE 射频也关了！但 `_bleServer` 等指针没清空**
3. `_startBLE()` 检查 `_bleServer != nullptr` → **true（野指针）** → 走"重开广播"路径
4. `_bleAdv->start()` 在已销毁的 BLE 栈上调用 → 什么都没发生

冷启动没问题是因为第一次 `_bleServer` 是 `nullptr`，走的是完整初始化。


Write

# BLE 二次配网修复计划

## 上下文
BLE 第一次（冷启动）配网正常，但配网成功后再次长按进入配网，蓝牙信号消失。

## 根因
`_startPortal()` 中 `WiFi.mode(WIFI_OFF)` 会同时关掉 BLE 射频栈，但 `_bleServer`/`_bleAdv` 等指针未清空。之后 `_startBLE()` 检查到非 null 指针，错误地走"仅重开广播"路径，在已销毁的 BLE 栈上调用 `_bleAdv->start()` 无效。

## 修改方案

### 文件: `src/wifi_manager.cpp`

**1. `_startPortal()` — 移除 `WiFi.mode(WIFI_OFF)`**

原代码:
```cpp
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);   // 这会杀死 BLE!
delay(200);
WiFi.mode(WIFI_AP);
```

改为直接切换到 AP 模式，不经过 OFF:
```cpp
WiFi.disconnect(true);
delay(200);
WiFi.mode(WIFI_AP);    // 直接从 STA→AP, BLE 栈不受影响
```

ESP32 支持 WiFi STA/AP 模式直接切换，不需要 OFF 中转。且 ESP32 BLE + WiFi AP 可以通过时分复用共存。

**2. `_stopBLE()` — 增加指针清空**

在 `_bleAdv->stop()` 后清空所有 BLE 指针，防止任何情况下出现野指针:
```cpp
_bleServer = nullptr;
_bleAdv = nullptr;
_bleInfoChar = nullptr;
_bleChar = nullptr;
```

**3. `_startBLE()` — 移除无效的"重开广播"路径**

既然 `_stopBLE()` 会清空指针（且 WiFi 不再杀 BLE），直接去掉 `if (_bleServer != nullptr)` 的重开路径，统一走完整初始化:
```cpp
void WiFiMgmt::_startBLE() {
    BLEDevice::init("HealthMonitor");
    _bleServer = BLEDevice::createServer();
    // ... 完整初始化
}
```

等等 — 这会导致问题: 第二次进入时 `BLEDevice::init()` 会因为已初始化而失败。所以需要先 deinit。

但是 `BLEDevice::deinit(true)` + 再 `init()` 在 ESP32 上有已知问题…

**更好的方案 (最终选择):**

不调 deinit/init 循环。改为:
- `_stopBLE()`: 停止广播 + 清空指针
- `_startPortal()`: 去掉 `WiFi.mode(WIFI_OFF)` → BLE 栈存活
- `_startBLE()`: 清空指针后必然走完整 init（但现在 init 会失败因为 BLE 已初始化）

等等这不对...

**真正正确的方案:**

关键是: `WiFi.mode(WIFI_OFF)` 后会杀 BLE，去掉就行。BLE 栈在整个 session 中保持存活。

1. `_startPortal()`: 移除 `WiFi.mode(WIFI_OFF)` 行
2. `_stopBLE()`: 仅停止广播，不清空指针（保留给重开路径用）
3. `_startBLE()`: 保持现有逻辑 — 如果 `_bleServer != nullptr` 则仅 `_bleAdv->start()`

第二次进配网时 BLE 栈一直存活，`_bleAdv->start()` 有效。

## 验证
1. 编译烧录
2. 冷启动 → 蓝牙 `HealthMon` 可搜到 ✅
3. BLE/Web 配网成功连接 WiFi ✅
4. 长按重新配网 → 蓝牙 `HealthMon` 仍然可搜到 ✅
5. 再次配网成功 ✅