6 Commits 3026fddea4 ... 38da5b0bc4

Autor SHA1 Mensaje Fecha
  moki 38da5b0bc4 恢复出厂设置功能 hace 1 día
  moki 2ed9da97ef 恢复出厂设置功能 hace 1 día
  moki fa3b20dc9b 恢复出厂设置功能 hace 1 día
  moki c387506509 恢复出厂设置功能 hace 1 día
  moki ffa909e0d0 去除ota hace 1 día
  moki 28a1cdaadf 修改固件 hace 1 día
Se han modificado 3 ficheros con 159 adiciones y 183 borrados
  1. 4 0
      docs/BLE_API.md
  2. 79 36
      firmware/README.md
  3. 76 147
      firmware/ai_light/ai_light.ino

+ 4 - 0
docs/BLE_API.md

@@ -91,6 +91,10 @@
 
 **注意**:所有字段都是可选的,只更新提供的字段,未提供的字段保持原值。
 
+## 恢复出厂设置
+
+连续短按 BOOT 按钮 3 下(1.5 秒内)可恢复出厂设置,清除所有配置并重启。
+
 ## Go 客户端示例
 
 ### 依赖

+ 79 - 36
firmware/README.md

@@ -6,31 +6,10 @@
 
 `ai_light.ino` 支持 BLE + MQTT 双模式,通过 BOOT 按钮切换。
 
-## Web Bluetooth 配置页面
-
-`web/ble-config.html` 是一个独立的 Web 页面,通过浏览器的 Web Bluetooth API 连接设备进行配置。
-
-### 使用方法
-
-1. 用 **Chrome / Edge / Opera** 浏览器打开 `web/ble-config.html`
-2. 点击 **Connect** 按钮,选择 `AI-Light` 设备
-3. 连接后可查看设备状态、切换灯效、配置 WiFi/MQTT 参数
-4. 点击 **Save & Restart** 保存配置
-
-### 功能
-
-| 功能 | 说明 |
-|------|------|
-| 设备状态 | 显示 WiFi/MQTT 连接状态、当前灯效 |
-| 灯效控制 | 点击按钮切换灯效模式 |
-| WiFi 配置 | SSID、密码 |
-| MQTT 配置 | Broker、Port、用户名、密码、Client ID、Topic |
-
-### 注意事项
-
-- 需要支持 Web Bluetooth 的浏览器(Chrome/Edge/Opera)
-- 首次使用需在 HTTPS 环境或 localhost 打开
-- ESP32 需在 BLE 广播范围内(约 10 米)
+| 通信方式 | 灯效控制 | 配置修改 |
+|---------|---------|---------|
+| BLE | ✅ | ✅ |
+| MQTT | ✅ | ✅ |
 
 ## BLE 参数(双模式版)
 
@@ -55,9 +34,17 @@ Config Characteristic: b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001  (WiFi/MQTT 配置)
 | `error` | 红灯快闪 | 任务失败 |
 | `alarm` | 红黄交替警灯 | 严重异常 |
 | `traffic` | 模拟红绿灯 | 展示模式 |
+| `red` | 红灯常亮 | 手动控制 |
+| `yellow` | 黄灯常亮 | 手动控制 |
+| `green` | 绿灯常亮 | 手动控制 |
+| `idle` | 同 traffic | 空闲状态 |
 | `off` | 全灭 | 关闭 |
 
-### WiFi/MQTT 配置(BLE)
+### WiFi/MQTT 配置
+
+配置可通过 BLE 或 MQTT 进行:
+
+#### BLE 配置
 
 向 Config 特征写入 JSON 配置,设备自动保存到 NVS 并重启:
 
@@ -70,36 +57,80 @@ Config Characteristic: b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001  (WiFi/MQTT 配置)
   "mqtt_user": "mqtt用户名",
   "mqtt_pass": "mqtt密码",
   "mqtt_client": "AI-Light",
-  "mqtt_topic": "opencode/status",
-  "mqtt_status": "openCodeLight/status"
+  "mqtt_topic": "agent/status",
+  "mqtt_status": "agentLight/status",
+  "mqtt_topic_config": "agent/status/config",
+  "pin_red": 4,
+  "pin_green": 3,
+  "pin_yellow": 2,
+  "factory_reset": true
 }
 ```
 
-## 双模式版(combined)
+#### MQTT 配置
+
+向配置 topic 发送 JSON 配置,设备自动保存到 NVS 并重启:
+
+- 默认 topic:`agent/status/config`
+- 可通过 `mqtt_topic_config` 字段自定义
+
+```bash
+# 示例:使用 mosquitto_pub 配置设备
+mosquitto_pub -h 192.168.1.100 -t "agent/status/config" -m '{
+  "wifi_ssid": "你的WiFi名",
+  "wifi_pass": "你的WiFi密码",
+  "mqtt_broker": "192.168.1.100"
+}'
+```
 
-`combined/ai_light.ino` 同时支持 BLE 和 MQTT:
+### 恢复出厂设置
+
+连续短按 BOOT 按钮 3 下(1.5 秒内)可恢复出厂设置:
+- 红灯闪烁 5 次表示正在清除配置
+- 清除完成后设备自动重启
+- 重启后进入 BLE 配置模式
+
+## 双模式版
+
+`ai_light.ino` 同时支持 BLE 和 MQTT:
 
 | 操作 | 效果 |
 |------|------|
-| 运行长按 BOOT 3 秒 | 切换 BLE/MQTT 模式并重启 |
+| 长按 BOOT 3 秒 | 切换 BLE/MQTT 模式并重启 |
+| 连续短按 BOOT 3 下 | 恢复出厂设置(清除所有配置) |
 
 - **开机始终启动 BLE**,可用于灯效控制和 WiFi/MQTT 配置
 - 默认 MQTT 模式,切换后自动保存到 NVS
-- 使用 `web/ble-config.html` 通过浏览器配置设备
 
 ## 硬件接线
 
-基于公共正极灯板:
+基于公共正极灯板(默认引脚,可通过配置修改)
 
 ```
 ESP32 3.3V  -> 灯板正极
-ESP32 IO2   -> 220Ω -> 绿灯
-ESP32 IO3   -> 220Ω -> 红灯
-ESP32 IO4   -> 220Ω -> 黄灯
+ESP32 IO4   -> 220Ω -> 红灯(默认)
+ESP32 IO3   -> 220Ω -> 绿灯(默认)
+ESP32 IO2   -> 220Ω -> 黄灯(默认)
+ESP32 IO8   -> 状态 LED(固定)
+ESP32 IO9   -> BOOT 按钮(固定)
 ```
 
 公共正极逻辑:GPIO LOW = 灯亮,GPIO HIGH = 灯灭
 
+### 引脚配置
+
+灯引脚可通过 BLE 或 MQTT 配置动态修改,无需重新烧录:
+
+```json
+{
+  "pin_red": 4,
+  "pin_green": 3,
+  "pin_yellow": 2
+}
+```
+
+配置后设备自动重启生效。状态 LED (IO8) 和 BOOT 按钮 (IO9) 固定不可修改。
+
 ## 烧录方法
 
 1. 安装 [Arduino IDE 2.x](https://www.arduino.cc/en/software)
@@ -117,3 +148,15 @@ ESP32 IO4   -> 220Ω -> 黄灯
 - `Preferences.h`(ESP32 内置)
 
 通过 Arduino IDE → Sketch → Include Library → Manage Libraries 安装。
+
+## 配置持久化
+
+配置保存在 ESP32 的 NVS(Non-Volatile Storage)分区,与固件分区独立:
+
+| 操作 | 配置保留? |
+|------|-----------|
+| Arduino IDE 正常上传 | ✅ 保留 |
+| 只刷写固件(不擦除 flash) | ✅ 保留 |
+| 擦除整个 flash 后刷写 | ❌ 丢失 |
+
+如需完全重置,可连续短按 BOOT 按钮 3 下或在 Arduino IDE 中擦除 flash。

+ 76 - 147
firmware/ai_light/ai_light.ino

@@ -6,8 +6,6 @@
 #include <BLEUtils.h>
 #include <BLE2902.h>
 #include <Preferences.h>
-#include <HTTPClient.h>
-#include <Update.h>
 
 // =====================================================
 // ESP32-C3 SuperMini + 原玩具公共正极灯板:BLE + MQTT 双模式
@@ -15,14 +13,15 @@
 // 功能:
 //   - 开机始终启动 BLE,支持灯效控制 + WiFi/MQTT 配置
 //   - MQTT 模式下同时连接 WiFi/MQTT
-//   - 运行时长按 BOOT 按钮 3 秒 → 切换 BLE/MQTT 模式并重启
+//   - 运行长按 BOOT 按钮 3 秒 → 切换 BLE/MQTT 模式并重启
+//   - 连续短按 BOOT 按钮 3 下 → 恢复出厂设置并重启
 //
 // BLE 配置:
 //   Service:  b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001
 //   Mode:     b8b7e002-... (读写/通知 - 灯效模式)
 //   Config:   b8b7e003-... (读写 - WiFi/MQTT 配置 JSON)
 //
-// 配置 JSON 格式(写入 Config 特征):
+// 配置 JSON 格式(写入 Config 特征或 MQTT config topic):
 //   {
 //     "wifi_ssid": "xxx",
 //     "wifi_pass": "xxx",
@@ -31,12 +30,12 @@
 //     "mqtt_user": "user",
 //     "mqtt_pass": "pass",
 //     "mqtt_client": "AI-Light",
-//     "mqtt_topic": "opencode/status",
-//     "mqtt_status": "openCodeLight/status",
+//     "mqtt_topic": "agent/status",
+//     "mqtt_status": "agentLight/status",
+//     "mqtt_topic_config": "agent/status/config",
 //     "pin_red": 4,
 //     "pin_green": 3,
-//     "pin_yellow": 2,
-//     "ota_url": "https://example.com/firmware.bin"
+//     "pin_yellow": 2
 //   }
 //
 // 接线方式(默认引脚,可通过配置修改):
@@ -76,6 +75,8 @@ const int GREEN_MAX = 220;
 const unsigned long NORMAL_MODE_TIMEOUT_MS = 5UL * 60UL * 1000UL;
 const unsigned long TRAFFIC_MODE_TIMEOUT_MS = 10UL * 60UL * 1000UL;
 const unsigned long LONG_PRESS_MS = 3000;
+const unsigned long TRIPLE_PRESS_WINDOW_MS = 1500;
+const int TRIPLE_PRESS_COUNT = 3;
 
 // =====================================================
 // 全局状态
@@ -102,10 +103,9 @@ uint16_t cfgMqttPort = 1883;
 String cfgMqttUser = "";
 String cfgMqttPass = "";
 String cfgMqttClient = "AI-Light";
-String cfgMqttTopic = "opencode/status";
-String cfgMqttStatus = "openCodeLight/status";
-String cfgOtaUrl = "";
-bool otaInProgress = false;
+String cfgMqttTopic = "agent/status";
+String cfgMqttStatus = "agentLight/status";
+String cfgMqttTopicConfig = "agent/status/config";
 
 
 // =====================================================
@@ -120,12 +120,12 @@ void loadConfig() {
   cfgMqttUser = preferences.getString("mqtt_user", "");
   cfgMqttPass = preferences.getString("mqtt_pass", "");
   cfgMqttClient = preferences.getString("mqtt_client", "AI-Light");
-  cfgMqttTopic = preferences.getString("mqtt_topic", "opencode/status");
-  cfgMqttStatus = preferences.getString("mqtt_status", "openCodeLight/status");
+  cfgMqttTopic = preferences.getString("mqtt_topic", "agent/status");
+  cfgMqttStatus = preferences.getString("mqtt_status", "agentLight/status");
+  cfgMqttTopicConfig = preferences.getString("mqtt_topic_cfg", "agent/status/config");
   redPin = preferences.getUInt("pin_red", 4);
   greenPin = preferences.getUInt("pin_green", 3);
   yellowPin = preferences.getUInt("pin_yellow", 2);
-  cfgOtaUrl = preferences.getString("ota_url", "");
 }
 
 bool isConfigComplete() {
@@ -142,11 +142,11 @@ String getConfigJson() {
   doc["mqtt_client"] = cfgMqttClient;
   doc["mqtt_topic"] = cfgMqttTopic;
   doc["mqtt_status"] = cfgMqttStatus;
+  doc["mqtt_topic_config"] = cfgMqttTopicConfig;
   doc["comm_mode"] = useMQTT ? 1 : 0;
   doc["pin_red"] = redPin;
   doc["pin_green"] = greenPin;
   doc["pin_yellow"] = yellowPin;
-  doc["ota_url"] = cfgOtaUrl;
   String out;
   serializeJson(doc, out);
   return out;
@@ -179,14 +179,14 @@ void saveConfigFromJson(const String& json) {
     preferences.putString("mqtt_topic", doc["mqtt_topic"].as<String>());
   if (doc.containsKey("mqtt_status"))
     preferences.putString("mqtt_status", doc["mqtt_status"].as<String>());
+  if (doc.containsKey("mqtt_topic_config"))
+    preferences.putString("mqtt_topic_cfg", doc["mqtt_topic_config"].as<String>());
   if (doc.containsKey("pin_red"))
     preferences.putUInt("pin_red", doc["pin_red"].as<uint8_t>());
   if (doc.containsKey("pin_green"))
     preferences.putUInt("pin_green", doc["pin_green"].as<uint8_t>());
   if (doc.containsKey("pin_yellow"))
     preferences.putUInt("pin_yellow", doc["pin_yellow"].as<uint8_t>());
-  if (doc.containsKey("ota_url"))
-    preferences.putString("ota_url", doc["ota_url"].as<String>());
 
   Serial.println("Config saved. Restarting...");
   delay(500);
@@ -439,13 +439,31 @@ void breathingGreen(int times) {
 unsigned long bootPressStart = 0;
 bool bootWasPressed = false;
 bool switchTriggered = false;
+unsigned long lastPressEnd = 0;
+int pressCount = 0;
+
+void factoryReset() {
+  Serial.println("Factory reset! Clearing all config...");
+  allOff();
+  for (int i = 0; i < 5; i++) {
+    setOnly(255, 0, 0); delay(200);
+    allOff(); delay(200);
+  }
+  preferences.clear();
+  Serial.println("NVS cleared. Restarting...");
+  delay(500);
+  ESP.restart();
+}
 
 void checkBootButton() {
   bool pressed = (digitalRead(BUTTON_PIN) == LOW);
+
   if (pressed && !bootWasPressed) {
     bootPressStart = millis();
     bootWasPressed = true;
+    switchTriggered = false;
   }
+
   if (pressed && bootWasPressed && !switchTriggered) {
     if (millis() - bootPressStart >= LONG_PRESS_MS) {
       switchTriggered = true;
@@ -461,9 +479,21 @@ void checkBootButton() {
       ESP.restart();
     }
   }
-  if (!pressed) {
+
+  if (!pressed && bootWasPressed) {
     bootWasPressed = false;
-    switchTriggered = false;
+    unsigned long pressDuration = millis() - bootPressStart;
+    if (pressDuration < LONG_PRESS_MS) {
+      if (millis() - lastPressEnd > TRIPLE_PRESS_WINDOW_MS) {
+        pressCount = 0;
+      }
+      pressCount++;
+      lastPressEnd = millis();
+      if (pressCount >= TRIPLE_PRESS_COUNT) {
+        pressCount = 0;
+        factoryReset();
+      }
+    }
   }
 }
 
@@ -494,17 +524,22 @@ void updateStatusLed() {
 // =====================================================
 
 class ServerCallbacks : public BLEServerCallbacks {
-  void onConnect(BLEServer* s) { bleDeviceConnected = true; Serial.println("BLE connected."); }
-  void onDisconnect(BLEServer* s) { bleDeviceConnected = false; Serial.println("BLE disconnected."); delay(100); BLEDevice::startAdvertising(); }
+  void onConnect(BLEServer* s) {
+    bleDeviceConnected = true;
+    Serial.println("BLE connected.");
+    if (currentMode == "init") setMode("traffic");
+  }
+  void onDisconnect(BLEServer* s) {
+    bleDeviceConnected = false;
+    Serial.println("BLE disconnected.");
+    delay(100);
+    BLEDevice::startAdvertising();
+  }
 };
 
 class ModeCharCallbacks : public BLECharacteristicCallbacks {
   void onWrite(BLECharacteristic* c) {
     String val = c->getValue();
-    if (val == "ota") {
-      performOTA();
-      return;
-    }
     setMode(val);
   }
   void onRead(BLECharacteristic* c) { c->setValue(currentMode.c_str()); }
@@ -552,6 +587,11 @@ void mqttCallback(char* topic, byte* payload, unsigned int length) {
   for (unsigned int i = 0; i < length; i++) message += (char)payload[i];
   Serial.print("MQTT: "); Serial.print(topic); Serial.print(" -> "); Serial.println(message);
 
+  if (String(topic) == cfgMqttTopicConfig) {
+    saveConfigFromJson(message);
+    return;
+  }
+
   JsonDocument doc;
   if (deserializeJson(doc, message)) { setMode(message); return; }
   const char* code = doc["code"];
@@ -564,10 +604,6 @@ void mqttCallback(char* topic, byte* payload, unsigned int length) {
     else if (c == "reasoning") setMode("thinking");
     else if (c == "using_tool") setMode("ai");
     else if (c == "error") setMode("error");
-    else if (c == "ota") {
-      performOTA();
-      return;
-    }
     else setMode(c);
   }
 }
@@ -581,7 +617,11 @@ bool connectWiFi() {
     Serial.printf("\nWiFi %d/5", retry);
     int attempts = 0;
     while (WiFi.status() != WL_CONNECTED && attempts < 60) {
-      delay(5); setOnly(100, 100, 100); updateStatusLed(); Serial.print("."); attempts++;
+      delay(5);
+      updateInit();
+      updateStatusLed();
+      Serial.print(".");
+      attempts++;
     }
     if (WiFi.status() == WL_CONNECTED) {
       Serial.printf("\nWiFi OK. IP: %s\n", WiFi.localIP().toString().c_str());
@@ -606,6 +646,7 @@ bool connectMQTT() {
     if (mqttClient.connect(cfgMqttClient.c_str(), cfgMqttUser.c_str(), cfgMqttPass.c_str())) {
       Serial.println("\nMQTT OK.");
       mqttClient.subscribe(cfgMqttTopic.c_str());
+      mqttClient.subscribe(cfgMqttTopicConfig.c_str());
       allOff(); breathingGreen(3); publishStatus();
       return true;
     }
@@ -620,114 +661,6 @@ void checkMQTTConnection() {
 }
 
 
-// =====================================================
-// OTA 固件更新
-// =====================================================
-
-void notifyOtaStatus(const String& status) {
-  if (pModeCharacteristic && bleDeviceConnected) {
-    pModeCharacteristic->setValue(status.c_str());
-    pModeCharacteristic->notify();
-  }
-  if (useMQTT && mqttClient.connected()) {
-    mqttClient.publish(cfgMqttStatus.c_str(), status.c_str(), true);
-  }
-}
-
-void performOTA() {
-  Serial.println("=== OTA Start ===");
-
-  if (otaInProgress) {
-    Serial.println("OTA already in progress");
-    return;
-  }
-
-  if (cfgOtaUrl.length() == 0) {
-    Serial.println("OTA URL not configured");
-    notifyOtaStatus("ota:no_url");
-    return;
-  }
-
-  if (WiFi.status() != WL_CONNECTED) {
-    Serial.println("WiFi not connected");
-    notifyOtaStatus("ota:no_wifi");
-    return;
-  }
-
-  otaInProgress = true;
-  notifyOtaStatus("ota:downloading");
-
-  HTTPClient http;
-  http.begin(cfgOtaUrl);
-  http.setTimeout(30000);
-
-  int httpCode = http.GET();
-  Serial.printf("HTTP GET: %d\n", httpCode);
-
-  if (httpCode != 200) {
-    Serial.printf("HTTP error: %d\n", httpCode);
-    notifyOtaStatus("ota:error");
-    http.end();
-    otaInProgress = false;
-    return;
-  }
-
-  int contentLength = http.getSize();
-  Serial.printf("Firmware size: %d bytes\n", contentLength);
-
-  if (contentLength <= 0) {
-    Serial.println("Invalid content length");
-    notifyOtaStatus("ota:error");
-    http.end();
-    otaInProgress = false;
-    return;
-  }
-
-  if (!Update.begin(contentLength)) {
-    Serial.println("Update.begin failed");
-    notifyOtaStatus("ota:error");
-    http.end();
-    otaInProgress = false;
-    return;
-  }
-
-  WiFiClient* stream = http.getStreamPtr();
-  uint8_t buf[1024];
-  int written = 0;
-  unsigned long lastProgress = millis();
-
-  while (http.connected() && written < contentLength) {
-    size_t size = stream->available();
-    if (size) {
-      int bytesRead = stream->readBytes(buf, min(size, sizeof(buf)));
-      size_t bytesWritten = Update.write(buf, bytesRead);
-      written += bytesWritten;
-
-      if (millis() - lastProgress >= 1000) {
-        Serial.printf("Progress: %d/%d (%.1f%%)\n", written, contentLength, (float)written / contentLength * 100);
-        lastProgress = millis();
-      }
-    }
-    delay(1);
-  }
-
-  Serial.printf("Written: %d/%d\n", written, contentLength);
-
-  if (Update.end(true)) {
-    Serial.println("OTA success!");
-    notifyOtaStatus("ota:success");
-    delay(1000);
-    ESP.restart();
-  } else {
-    Serial.printf("OTA error: %s\n", Update.errorString());
-    notifyOtaStatus("ota:error");
-  }
-
-  http.end();
-  otaInProgress = false;
-}
-
-
 // =====================================================
 // 初始化
 // =====================================================
@@ -746,7 +679,9 @@ void setup() {
   pinMode(STATUS_PIN, OUTPUT);
   pinMode(BUTTON_PIN, INPUT_PULLUP);
   digitalWrite(STATUS_PIN, HIGH);
-  allOff();
+
+  currentMode = "init";
+  modeStart = millis();
 
   Serial.println();
   Serial.println("=== AI-Light ===");
@@ -755,9 +690,6 @@ void setup() {
   Serial.printf("MQTT: %s\n", cfgMqttBroker.length() > 0 ? cfgMqttBroker.c_str() : "(not set)");
   Serial.printf("Pins: R=%d G=%d Y=%d\n", redPin, greenPin, yellowPin);
 
-  currentMode = "init";
-  modeStart = millis();
-
   if (useMQTT && isConfigComplete()) {
     wifiConnected = connectWiFi();
     if (wifiConnected) {
@@ -770,10 +702,7 @@ void setup() {
   }
 
   setupBLE();
-  delay(100);
-
-  setMode("traffic");
-  Serial.println("BLE config mode. Long press BOOT (3s) to switch.");
+  Serial.println("BLE advertising. Waiting for connection...");
 }