Forráskód Böngészése

通过web ble进行配置

moki 1 hete
szülő
commit
f11f5a8ed0
5 módosított fájl, 1320 hozzáadás és 3 törlés
  1. 277 0
      docs/BLE_API.md
  2. 71 3
      firmware/README.md
  3. 649 0
      firmware/combined/ai_light.ino
  4. 6 0
      go.sum
  5. 317 0
      web/ble-config.html

+ 277 - 0
docs/BLE_API.md

@@ -0,0 +1,277 @@
+# AI-Light BLE 接口文档
+
+## 设备信息
+
+| 项目 | 值 |
+|------|-----|
+| 设备名称 | `AI-Light` |
+| Service UUID | `b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001` |
+| Characteristic UUID | `b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001` |
+| Characteristic 属性 | READ / WRITE / NOTIFY |
+
+## 支持的模式
+
+| 模式 | 说明 | 灯效 |
+|------|------|------|
+| `init` | 初始化状态 | 三色一起呼吸 |
+| `thinking` | 思考中 | 绿→黄→红 连贯跑马灯 |
+| `ai` | AI 处理中 | 三色柔和波浪 |
+| `busy` | 忙碌状态 | 黄灯淡入淡出 |
+| `success` | 成功状态 | 绿灯常亮 |
+| `error` | 错误状态 | 红灯快速闪烁 |
+| `alarm` | 警告状态 | 红黄交替警灯 |
+| `traffic` | 交通灯模式 | 绿→黄→红 循环 |
+| `off` | 关闭 | 全灭 |
+| `red` | 红灯 | 红灯常亮 |
+| `yellow` | 黄灯 | 黄灯常亮 |
+| `green` | 绿灯 | 绿灯常亮 |
+| `idle` | 空闲(自动转为 traffic) | 同 traffic |
+
+## 超时规则
+
+- 普通模式运行 **5分钟** → 自动进入 `traffic`
+- `traffic` 运行 **10分钟** → 自动进入 `off`
+
+## Go 客户端示例
+
+### 依赖
+
+```bash
+go mod init ailight-client
+go get tinygo.org/x/bluetooth
+```
+
+### 完整代码
+
+```go
+package main
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"tinygo.org/x/bluetooth"
+)
+
+var (
+	serviceUUID  = bluetooth.NewUUID([16]byte{0xb8, 0xb7, 0xe0, 0x01, 0x7a, 0x6b, 0x4f, 0x4f, 0x9a, 0x8b, 0x11, 0xc0, 0xff, 0xee, 0x00, 0x01})
+	modeCharUUID = bluetooth.NewUUID([16]byte{0xb8, 0xb7, 0xe0, 0x02, 0x7a, 0x6b, 0x4f, 0x4f, 0x9a, 0x8b, 0x11, 0xc0, 0xff, 0xee, 0x00, 0x01})
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		fmt.Println("Usage: ailight-client <mode>")
+		fmt.Println("Modes: init, thinking, ai, busy, success, error, alarm, traffic, off, red, yellow, green")
+		os.Exit(1)
+	}
+	mode := os.Args[1]
+
+	adapter := bluetooth.DefaultAdapter
+	if err := adapter.Enable(); err != nil {
+		fmt.Printf("Failed to enable BLE adapter: %v\n", err)
+		os.Exit(1)
+	}
+
+	// 扫描并连接设备
+	fmt.Println("Scanning for AI-Light...")
+	found := false
+	var device bluetooth.Device
+
+	done := make(chan struct{})
+	go func() {
+		adapter.Scan(func(adapter *bluetooth.Adapter, scanResult bluetooth.ScanResult) {
+			if scanResult.LocalName() == "AI-Light" {
+				adapter.StopScan()
+				found = true
+				var err error
+				device, err = adapter.Connect(scanResult.Address, bluetooth.ConnectionParams{})
+				if err != nil {
+					fmt.Printf("Failed to connect: %v\n", err)
+					os.Exit(1)
+				}
+				close(done)
+			}
+		})
+	}()
+
+	select {
+	case <-done:
+		// 连接成功
+	case <-time.After(10 * time.Second):
+		fmt.Println("Connection timeout")
+		os.Exit(1)
+	}
+
+	if !found {
+		fmt.Println("Device not found")
+		os.Exit(1)
+	}
+
+	fmt.Println("Connected to AI-Light")
+
+	// 发现服务和特征
+	services, err := device.DiscoverServices([]bluetooth.UUID{serviceUUID})
+	if err != nil {
+		fmt.Printf("Failed to discover services: %v\n", err)
+		os.Exit(1)
+	}
+
+	if len(services) == 0 {
+		fmt.Println("Service not found")
+		os.Exit(1)
+	}
+
+	characteristics, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{modeCharUUID})
+	if err != nil {
+		fmt.Printf("Failed to discover characteristics: %v\n", err)
+		os.Exit(1)
+	}
+
+	if len(characteristics) == 0 {
+		fmt.Println("Characteristic not found")
+		os.Exit(1)
+	}
+
+	// 写入模式
+	_, err = characteristics[0].WriteString(mode)
+	if err != nil {
+		fmt.Printf("Write failed: %v\n", err)
+		os.Exit(1)
+	}
+
+	fmt.Printf("Sent mode: %s\n", mode)
+	
+	// 断开连接
+	device.Disconnect()
+	fmt.Println("Disconnected")
+}
+```
+
+### 使用方式
+
+```bash
+# 编译
+go build -o ailight-client main.go
+
+# 切换模式
+./ailight-client thinking
+./ailight-client busy
+./ailight-client success
+./ailight-client error
+./ailight-client traffic
+./ailight-client off
+```
+
+### 封装为函数
+
+```go
+package ailight
+
+import (
+	"fmt"
+	"time"
+
+	"tinygo.org/x/bluetooth"
+)
+
+var (
+	serviceUUID  = bluetooth.NewUUID([16]byte{0xb8, 0xb7, 0xe0, 0x01, 0x7a, 0x6b, 0x4f, 0x4f, 0x9a, 0x8b, 0x11, 0xc0, 0xff, 0xee, 0x00, 0x01})
+	modeCharUUID = bluetooth.NewUUID([16]byte{0xb8, 0xb7, 0xe0, 0x02, 0x7a, 0x6b, 0x4f, 0x4f, 0x9a, 0x8b, 0x11, 0xc0, 0xff, 0xee, 0x00, 0x01})
+)
+
+// SetMode 设置灯效模式
+func SetMode(mode string) error {
+	adapter := bluetooth.DefaultAdapter
+	if err := adapter.Enable(); err != nil {
+		return fmt.Errorf("enable adapter: %w", err)
+	}
+
+	// 扫描设备
+	var device bluetooth.Device
+	found := make(chan bool, 1)
+
+	go func() {
+		adapter.Scan(func(adapter *bluetooth.Adapter, scanResult bluetooth.ScanResult) {
+			if scanResult.LocalName() == "AI-Light" {
+				adapter.StopScan()
+				var err error
+				device, err = adapter.Connect(scanResult.Address, bluetooth.ConnectionParams{})
+				found <- err == nil
+			}
+		})
+	}()
+
+	select {
+	case ok := <-found:
+		if !ok {
+			return fmt.Errorf("connect failed")
+		}
+	case <-time.After(10 * time.Second):
+		return fmt.Errorf("scan timeout")
+	}
+	defer device.Disconnect()
+
+	// 发现服务
+	services, err := device.DiscoverServices([]bluetooth.UUID{serviceUUID})
+	if err != nil || len(services) == 0 {
+		return fmt.Errorf("service not found")
+	}
+
+	// 发现特征
+	characteristics, err := services[0].DiscoverCharacteristics([]bluetooth.UUID{modeCharUUID})
+	if err != nil || len(characteristics) == 0 {
+		return fmt.Errorf("characteristic not found")
+	}
+
+	// 写入模式
+	_, err = characteristics[0].WriteString(mode)
+	if err != nil {
+		return fmt.Errorf("write failed: %w", err)
+	}
+
+	return nil
+}
+```
+
+### 调用示例
+
+```go
+package main
+
+import (
+	"fmt"
+	"ailight"
+)
+
+func main() {
+	// AI 开始思考
+	ailight.SetMode("thinking")
+	
+	// 执行任务...
+	
+	// 任务完成
+	ailight.SetMode("success")
+	
+	// 出错
+	ailight.SetMode("error")
+	
+	// 关闭
+	ailight.SetMode("off")
+}
+```
+
+## 平台支持
+
+| 平台 | 支持情况 | 备注 |
+|------|----------|------|
+| Linux | ✅ 完整支持 | 需要 BlueZ |
+| Windows | ⚠️ 部分支持 | 需要额外配置 |
+| Raspberry Pi | ✅ 完整支持 | 推荐用于部署 |
+
+## 注意事项
+
+1. **连接稳定性**:每次发送指令需要重新扫描和连接,建议封装连接池
+2. **超时处理**:扫描和连接都设置了 10 秒超时
+3. **并发控制**:BLE 连接不支持并发,需要加锁
+4. **重连机制**:建议实现自动重连逻辑

+ 71 - 3
firmware/README.md

@@ -8,16 +8,46 @@
 |------|------|----------|------|
 | BLE | `ble/ai_light.ino` | BLE 蓝牙 | 无需 WiFi,适合本地使用 |
 | MQTT | `mqtt/ai_light.ino` | WiFi + MQTT | 远程控制(V2 红黄互换 + 黄绿互换) |
+| **双模式** | `combined/ai_light.ino` | BLE + MQTT | **推荐**,BOOT 按钮切换模式 |
 
-## BLE 参数
+## 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 参数(双模式版)
 
 ```
 Device Name:          AI-Light
 Service UUID:         b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001
-Characteristic UUID:  b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001
+Mode Characteristic:  b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001  (灯效控制)
+Config Characteristic: b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001  (WiFi/MQTT 配置)
 ```
 
-写入 UTF-8 字符串即可控制灯效,支持的模式:
+### 灯效控制
+
+向 Mode 特征写入 UTF-8 字符串即可控制灯效:
 
 | 模式 | 灯效 | 典型场景 |
 |------|------|----------|
@@ -31,6 +61,36 @@ Characteristic UUID:  b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001
 | `traffic` | 模拟红绿灯 | 展示模式 |
 | `off` | 全灭 | 关闭 |
 
+### WiFi/MQTT 配置(BLE)
+
+向 Config 特征写入 JSON 配置,设备自动保存到 NVS 并重启:
+
+```json
+{
+  "wifi_ssid": "你的WiFi名",
+  "wifi_pass": "你的WiFi密码",
+  "mqtt_broker": "192.168.1.100",
+  "mqtt_port": 1883,
+  "mqtt_user": "mqtt用户名",
+  "mqtt_pass": "mqtt密码",
+  "mqtt_client": "AI-Light",
+  "mqtt_topic": "opencode/status",
+  "mqtt_status": "openCodeLight/status"
+}
+```
+
+## 双模式版(combined)
+
+`combined/ai_light.ino` 同时支持 BLE 和 MQTT:
+
+| 操作 | 效果 |
+|------|------|
+| 运行长按 BOOT 3 秒 | 切换 BLE/MQTT 模式并重启 |
+
+- **开机始终启动 BLE**,可用于灯效控制和 WiFi/MQTT 配置
+- 默认 MQTT 模式,切换后自动保存到 NVS
+- 使用 `web/ble-config.html` 通过浏览器配置设备
+
 ## 硬件接线
 
 基于公共正极灯板:
@@ -58,4 +118,12 @@ ESP32 IO4   -> 220Ω -> 黄灯
 - `PubSubClient.h`
 - `ArduinoJson.h`
 
+## 依赖库(双模式版)
+
+- `WiFi.h`(ESP32 内置)
+- `PubSubClient.h`
+- `ArduinoJson.h`
+- `BLEDevice.h`(ESP32 内置)
+- `Preferences.h`(ESP32 内置)
+
 通过 Arduino IDE → Sketch → Include Library → Manage Libraries 安装。

+ 649 - 0
firmware/combined/ai_light.ino

@@ -0,0 +1,649 @@
+#include <WiFi.h>
+#include <PubSubClient.h>
+#include <ArduinoJson.h>
+#include <BLEDevice.h>
+#include <BLEServer.h>
+#include <BLEUtils.h>
+#include <BLE2902.h>
+#include <Preferences.h>
+
+// =====================================================
+// ESP32-C3 SuperMini + 原玩具公共正极灯板:BLE + MQTT 双模式
+//
+// 功能:
+//   - 开机始终启动 BLE,支持灯效控制 + WiFi/MQTT 配置
+//   - MQTT 模式下同时连接 WiFi/MQTT
+//   - 运行时长按 BOOT 按钮 3 秒 → 切换 BLE/MQTT 模式并重启
+//
+// BLE 配置:
+//   Service:  b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001
+//   Mode:     b8b7e002-... (读写/通知 - 灯效模式)
+//   Config:   b8b7e003-... (读写 - WiFi/MQTT 配置 JSON)
+//
+// 配置 JSON 格式(写入 Config 特征):
+//   {
+//     "wifi_ssid": "xxx",
+//     "wifi_pass": "xxx",
+//     "mqtt_broker": "192.168.1.100",
+//     "mqtt_port": 1883,
+//     "mqtt_user": "user",
+//     "mqtt_pass": "pass",
+//     "mqtt_client": "AI-Light",
+//     "mqtt_topic": "opencode/status",
+//     "mqtt_status": "openCodeLight/status"
+//   }
+//
+// 接线方式(V2 版本:红黄互换 + 黄绿互换):
+//   ESP32 3.3V  -> 原灯板 + / 原电池正极
+//   ESP32 IO2   -> 220Ω -> L1 控制点 = 绿灯
+//   ESP32 IO3   -> 220Ω -> L2 控制点 = 红灯
+//   ESP32 IO4   -> 220Ω -> L3 控制点 = 黄灯
+// =====================================================
+
+// =====================================================
+// BLE 配置
+// =====================================================
+const char* BLE_DEVICE_NAME = "AI-Light";
+#define SERVICE_UUID        "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001"
+#define MODE_CHAR_UUID      "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001"
+#define CONFIG_CHAR_UUID    "b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001"
+
+// =====================================================
+// 引脚定义(V2 版本:红黄互换 + 黄绿互换)
+// =====================================================
+const int GREEN_PIN = 2;
+const int RED_PIN = 3;
+const int YELLOW_PIN = 4;
+const int WIFI_LED_PIN = 8;
+const int BOOT_PIN = 9;
+
+const int PWM_FREQ = 5000;
+const int PWM_RESOLUTION = 8;
+
+const int RED_MAX = 255;
+const int YELLOW_MAX = 220;
+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;
+
+// =====================================================
+// 全局状态
+// =====================================================
+String currentMode = "init";
+unsigned long modeStart = 0;
+bool useMQTT = true;
+
+WiFiClient wifiClient;
+PubSubClient mqttClient(wifiClient);
+
+BLEServer* pServer = nullptr;
+BLECharacteristic* pModeCharacteristic = nullptr;
+BLECharacteristic* pConfigCharacteristic = nullptr;
+bool bleDeviceConnected = false;
+
+Preferences preferences;
+
+String cfgWifiSsid = "";
+String cfgWifiPass = "";
+String cfgMqttBroker = "";
+uint16_t cfgMqttPort = 1883;
+String cfgMqttUser = "";
+String cfgMqttPass = "";
+String cfgMqttClient = "AI-Light";
+String cfgMqttTopic = "opencode/status";
+String cfgMqttStatus = "openCodeLight/status";
+
+
+// =====================================================
+// NVS 配置读写
+// =====================================================
+
+void loadConfig() {
+  cfgWifiSsid = preferences.getString("wifi_ssid", "");
+  cfgWifiPass = preferences.getString("wifi_pass", "");
+  cfgMqttBroker = preferences.getString("mqtt_broker", "");
+  cfgMqttPort = preferences.getUInt("mqtt_port", 1883);
+  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");
+}
+
+bool isConfigComplete() {
+  return cfgWifiSsid.length() > 0 && cfgMqttBroker.length() > 0;
+}
+
+String getConfigJson() {
+  JsonDocument doc;
+  doc["wifi_ssid"] = cfgWifiSsid;
+  doc["mqtt_broker"] = cfgMqttBroker;
+  doc["mqtt_port"] = cfgMqttPort;
+  doc["mqtt_user"] = cfgMqttUser;
+  doc["mqtt_client"] = cfgMqttClient;
+  doc["mqtt_topic"] = cfgMqttTopic;
+  doc["mqtt_status"] = cfgMqttStatus;
+  doc["comm_mode"] = useMQTT ? 1 : 0;
+  String out;
+  serializeJson(doc, out);
+  return out;
+}
+
+void saveConfigFromJson(const String& json) {
+  JsonDocument doc;
+  DeserializationError err = deserializeJson(doc, json);
+  if (err) {
+    Serial.print("Config JSON parse error: ");
+    Serial.println(err.c_str());
+    return;
+  }
+
+  if (doc.containsKey("wifi_ssid"))
+    preferences.putString("wifi_ssid", doc["wifi_ssid"].as<String>());
+  if (doc.containsKey("wifi_pass"))
+    preferences.putString("wifi_pass", doc["wifi_pass"].as<String>());
+  if (doc.containsKey("mqtt_broker"))
+    preferences.putString("mqtt_broker", doc["mqtt_broker"].as<String>());
+  if (doc.containsKey("mqtt_port"))
+    preferences.putUInt("mqtt_port", doc["mqtt_port"].as<uint16_t>());
+  if (doc.containsKey("mqtt_user"))
+    preferences.putString("mqtt_user", doc["mqtt_user"].as<String>());
+  if (doc.containsKey("mqtt_pass"))
+    preferences.putString("mqtt_pass", doc["mqtt_pass"].as<String>());
+  if (doc.containsKey("mqtt_client"))
+    preferences.putString("mqtt_client", doc["mqtt_client"].as<String>());
+  if (doc.containsKey("mqtt_topic"))
+    preferences.putString("mqtt_topic", doc["mqtt_topic"].as<String>());
+  if (doc.containsKey("mqtt_status"))
+    preferences.putString("mqtt_status", doc["mqtt_status"].as<String>());
+
+  Serial.println("Config saved. Restarting...");
+  delay(500);
+  ESP.restart();
+}
+
+
+// =====================================================
+// 基础工具函数:公共正极反相输出
+// =====================================================
+
+void writeLed(int pin, int value) {
+  value = constrain(value, 0, 255);
+  int pwmValue = 255 - value;
+  ledcWrite(pin, pwmValue);
+}
+
+void allOff() {
+  writeLed(RED_PIN, 0);
+  writeLed(YELLOW_PIN, 0);
+  writeLed(GREEN_PIN, 0);
+}
+
+void setOnly(int red, int yellow, int green) {
+  writeLed(RED_PIN, constrain(red, 0, RED_MAX));
+  writeLed(YELLOW_PIN, constrain(yellow, 0, YELLOW_MAX));
+  writeLed(GREEN_PIN, constrain(green, 0, GREEN_MAX));
+}
+
+int triWave(unsigned long t, unsigned long period, int maxValue) {
+  unsigned long x = t % period;
+  if (x < period / 2) return map(x, 0, period / 2, 0, maxValue);
+  return map(x, period / 2, period, maxValue, 0);
+}
+
+int fadeInOutBrightness(
+  unsigned long t, unsigned long fadeIn, unsigned long hold,
+  unsigned long fadeOut, unsigned long offTime, int maxValue
+) {
+  unsigned long total = fadeIn + hold + fadeOut + offTime;
+  unsigned long x = t % total;
+  if (x < fadeIn) return map(x, 0, fadeIn, 0, maxValue);
+  x -= fadeIn;
+  if (x < hold) return maxValue;
+  x -= hold;
+  if (x < fadeOut) return map(x, 0, fadeOut, maxValue, 0);
+  return 0;
+}
+
+void fadeToStatic(int targetRed, int targetYellow, int targetGreen, int fadeMs = 80) {
+  allOff();
+  int steps = 12;
+  int delayPerStep = max(1, fadeMs / steps);
+  for (int i = 0; i <= steps; i++) {
+    float p = (float)i / steps;
+    setOnly(targetRed * p, targetYellow * p, targetGreen * p);
+    delay(delayPerStep);
+  }
+}
+
+
+// =====================================================
+// 模式处理
+// =====================================================
+
+bool isValidMode(String mode) {
+  return (
+    mode == "red" || mode == "yellow" || mode == "green" ||
+    mode == "busy" || mode == "error" || mode == "thinking" ||
+    mode == "ai" || mode == "success" || mode == "traffic" ||
+    mode == "alarm" || mode == "init" || mode == "off" || mode == "idle"
+  );
+}
+
+void publishStatus() {
+  if (useMQTT && mqttClient.connected()) {
+    mqttClient.publish(cfgMqttStatus.c_str(), currentMode.c_str(), true);
+  }
+}
+
+void notifyMode() {
+  if (pModeCharacteristic) {
+    pModeCharacteristic->setValue(currentMode.c_str());
+    if (bleDeviceConnected) pModeCharacteristic->notify();
+  }
+}
+
+void setMode(String mode) {
+  mode.trim();
+  mode.toLowerCase();
+  if (!isValidMode(mode)) {
+    Serial.print("Unknown mode: ");
+    Serial.println(mode);
+    return;
+  }
+  if (mode == "idle") mode = "traffic";
+
+  currentMode = mode;
+  modeStart = millis();
+
+  Serial.print("Mode: ");
+  Serial.println(currentMode);
+
+  if (mode == "red") fadeToStatic(RED_MAX, 0, 0, 80);
+  else if (mode == "yellow") fadeToStatic(0, YELLOW_MAX, 0, 80);
+  else if (mode == "green") fadeToStatic(0, 0, GREEN_MAX, 80);
+  else if (mode == "success") setOnly(0, 0, GREEN_MAX);
+  else if (mode == "off") allOff();
+
+  publishStatus();
+  notifyMode();
+}
+
+void autoTimeoutCheck() {
+  unsigned long elapsed = millis() - modeStart;
+  if (currentMode == "off") return;
+  if (currentMode == "traffic") {
+    if (elapsed >= TRAFFIC_MODE_TIMEOUT_MS) setMode("off");
+    return;
+  }
+  if (elapsed >= NORMAL_MODE_TIMEOUT_MS) setMode("traffic");
+}
+
+
+// =====================================================
+// 灯效模式
+// =====================================================
+
+void updateBusy() {
+  unsigned long t = millis() - modeStart;
+  int y = fadeInOutBrightness(t, 80, 500, 120, 500, YELLOW_MAX);
+  setOnly(0, y, 0);
+}
+
+void updateError() {
+  unsigned long t = millis() - modeStart;
+  int r = fadeInOutBrightness(t, 40, 180, 80, 180, RED_MAX);
+  setOnly(r, 0, 0);
+}
+
+void updateThinking() {
+  unsigned long t = millis() - modeStart;
+  const unsigned long period = 1050;
+  unsigned long x = t % period;
+  int g = 0, y = 0, r = 0;
+  if (x < 350) {
+    g = map(x, 0, 350, GREEN_MAX, 70);
+    y = map(x, 0, 350, 20, YELLOW_MAX);
+  } else if (x < 700) {
+    unsigned long p = x - 350;
+    g = map(p, 0, 350, 70, 0);
+    y = map(p, 0, 350, YELLOW_MAX, 70);
+    r = map(p, 0, 350, 20, RED_MAX);
+  } else {
+    unsigned long p = x - 700;
+    g = map(p, 0, 350, 20, GREEN_MAX);
+    y = map(p, 0, 350, 70, 0);
+    r = map(p, 0, 350, RED_MAX, 70);
+  }
+  setOnly(r, y, g);
+}
+
+void updateAi() {
+  unsigned long t = millis() - modeStart;
+  const unsigned long period = 1800;
+  unsigned long x = t % period;
+  int g = triWave((x + 0) % period, period, 150);
+  int y = triWave((x + period / 3) % period, period, 140);
+  int r = triWave((x + 2 * period / 3) % period, period, 170);
+  setOnly(r, y, g);
+}
+
+void updateSuccess() { setOnly(0, 0, GREEN_MAX); }
+
+void updateAlarm() {
+  unsigned long t = millis() - modeStart;
+  const unsigned long phaseMs = 260;
+  int phase = (t / phaseMs) % 2;
+  unsigned long inside = t % phaseMs;
+  int brightness;
+  if (inside < 60) brightness = map(inside, 0, 60, 0, 255);
+  else if (inside < 180) brightness = 255;
+  else brightness = map(inside, 180, phaseMs, 255, 0);
+  if (phase == 0) setOnly(brightness, 0, 0);
+  else setOnly(0, min(brightness, YELLOW_MAX), 0);
+}
+
+void updateTraffic() {
+  unsigned long t = (millis() - modeStart) % 15000;
+  if (t < 5000) {
+    setOnly(0, 0, GREEN_MAX);
+  } else if (t < 6500) {
+    unsigned long phase = (t - 5000) % 500;
+    int g = 0;
+    if (phase < 60) g = map(phase, 0, 60, 0, GREEN_MAX);
+    else if (phase < 230) g = GREEN_MAX;
+    else if (phase < 320) g = map(phase, 230, 320, GREEN_MAX, 0);
+    setOnly(0, 0, g);
+  } else if (t < 8500) {
+    setOnly(0, YELLOW_MAX, 0);
+  } else if (t < 13500) {
+    setOnly(RED_MAX, 0, 0);
+  } else {
+    unsigned long phase = (t - 13500) % 500;
+    int r = 0;
+    if (phase < 60) r = map(phase, 0, 60, 0, RED_MAX);
+    else if (phase < 230) r = RED_MAX;
+    else if (phase < 320) r = map(phase, 230, 320, RED_MAX, 0);
+    setOnly(r, 0, 0);
+  }
+}
+
+void updateInit() {
+  unsigned long t = millis() - modeStart;
+  const unsigned long period = 2500;
+  unsigned long x = t % period;
+  int brightness;
+  if (x < 800) brightness = map(x, 0, 800, 0, 200);
+  else if (x < 1200) brightness = 200;
+  else if (x < 2000) brightness = map(x, 1200, 2000, 200, 0);
+  else brightness = 0;
+  setOnly(brightness, brightness, brightness);
+}
+
+void breathingGreen(int times) {
+  const unsigned long period = 2500;
+  for (int i = 0; i < times; i++) {
+    unsigned long start = millis();
+    while (millis() - start < period) {
+      unsigned long t = millis() - start;
+      int brightness;
+      if (t < 800) brightness = map(t, 0, 800, 0, GREEN_MAX);
+      else if (t < 1200) brightness = GREEN_MAX;
+      else if (t < 2000) brightness = map(t, 1200, 2000, GREEN_MAX, 0);
+      else brightness = 0;
+      setOnly(0, 0, brightness);
+      delay(5);
+    }
+  }
+  allOff();
+}
+
+
+// =====================================================
+// BOOT 按钮处理
+// =====================================================
+
+unsigned long bootPressStart = 0;
+bool bootWasPressed = false;
+bool switchTriggered = false;
+
+void checkBootButton() {
+  bool pressed = (digitalRead(BOOT_PIN) == LOW);
+  if (pressed && !bootWasPressed) {
+    bootPressStart = millis();
+    bootWasPressed = true;
+  }
+  if (pressed && bootWasPressed && !switchTriggered) {
+    if (millis() - bootPressStart >= LONG_PRESS_MS) {
+      switchTriggered = true;
+      Serial.println("BOOT long press -> switching mode...");
+      allOff();
+      for (int i = 0; i < 3; i++) {
+        setOnly(100, 100, 100); delay(100);
+        allOff(); delay(100);
+      }
+      useMQTT = !useMQTT;
+      preferences.putUInt("comm_mode", useMQTT ? 1 : 0);
+      delay(200);
+      ESP.restart();
+    }
+  }
+  if (!pressed) {
+    bootWasPressed = false;
+    switchTriggered = false;
+  }
+}
+
+
+// =====================================================
+// 状态 LED
+// =====================================================
+
+unsigned long lastStatusLedToggle = 0;
+bool statusLedState = false;
+
+void updateStatusLed() {
+  bool connected = useMQTT ? (WiFi.status() == WL_CONNECTED) : bleDeviceConnected;
+  if (connected) {
+    digitalWrite(WIFI_LED_PIN, LOW);
+    return;
+  }
+  if (millis() - lastStatusLedToggle >= 500) {
+    statusLedState = !statusLedState;
+    digitalWrite(WIFI_LED_PIN, statusLedState ? LOW : HIGH);
+    lastStatusLedToggle = millis();
+  }
+}
+
+
+// =====================================================
+// BLE
+// =====================================================
+
+class ServerCallbacks : public BLEServerCallbacks {
+  void onConnect(BLEServer* s) { bleDeviceConnected = true; Serial.println("BLE connected."); }
+  void onDisconnect(BLEServer* s) { bleDeviceConnected = false; Serial.println("BLE disconnected."); BLEDevice::startAdvertising(); }
+};
+
+class ModeCharCallbacks : public BLECharacteristicCallbacks {
+  void onWrite(BLECharacteristic* c) { setMode(c->getValue()); }
+  void onRead(BLECharacteristic* c) { c->setValue(currentMode.c_str()); }
+};
+
+class ConfigCharCallbacks : public BLECharacteristicCallbacks {
+  void onWrite(BLECharacteristic* c) { saveConfigFromJson(c->getValue()); }
+  void onRead(BLECharacteristic* c) { c->setValue(getConfigJson().c_str()); }
+};
+
+void setupBLE() {
+  Serial.println("Starting BLE...");
+  BLEDevice::init(BLE_DEVICE_NAME);
+  pServer = BLEDevice::createServer();
+  pServer->setCallbacks(new ServerCallbacks());
+  BLEService* pService = pServer->createService(SERVICE_UUID);
+
+  pModeCharacteristic = pService->createCharacteristic(MODE_CHAR_UUID,
+    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY);
+  pModeCharacteristic->setCallbacks(new ModeCharCallbacks());
+  pModeCharacteristic->setValue(currentMode.c_str());
+  pModeCharacteristic->addDescriptor(new BLE2902());
+
+  pConfigCharacteristic = pService->createCharacteristic(CONFIG_CHAR_UUID,
+    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE);
+  pConfigCharacteristic->setCallbacks(new ConfigCharCallbacks());
+
+  pService->start();
+  BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
+  pAdvertising->addServiceUUID(SERVICE_UUID);
+  pAdvertising->setScanResponse(true);
+  pAdvertising->setMinPreferred(0x06);
+  pAdvertising->setMinPreferred(0x12);
+  BLEDevice::startAdvertising();
+  Serial.println("BLE advertising.");
+}
+
+
+// =====================================================
+// MQTT
+// =====================================================
+
+void mqttCallback(char* topic, byte* payload, unsigned int length) {
+  String message = "";
+  for (unsigned int i = 0; i < length; i++) message += (char)payload[i];
+  Serial.print("MQTT: "); Serial.print(topic); Serial.print(" -> "); Serial.println(message);
+
+  JsonDocument doc;
+  if (deserializeJson(doc, message)) { setMode(message); return; }
+  const char* code = doc["code"];
+  if (code) {
+    String c = String(code);
+    if (c == "idle") setMode("idle");
+    else if (c == "busy" || c == "running") setMode("busy");
+    else if (c == "retry" || c == "permission") setMode("alarm");
+    else if (c == "pending") setMode("yellow");
+    else if (c == "reasoning") setMode("thinking");
+    else if (c == "using_tool") setMode("ai");
+    else if (c == "error") setMode("error");
+    else setMode(c);
+  }
+}
+
+void connectWiFi() {
+  Serial.print("Connecting WiFi");
+  WiFi.mode(WIFI_STA);
+  WiFi.begin(cfgWifiSsid.c_str(), cfgWifiPass.c_str());
+
+  for (int retry = 1; retry <= 5; retry++) {
+    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++;
+    }
+    if (WiFi.status() == WL_CONNECTED) break;
+    Serial.println("\nFailed, retrying..."); delay(2000);
+  }
+
+  if (WiFi.status() != WL_CONNECTED) {
+    Serial.println("\nWiFi failed. Restarting...");
+    delay(1000);
+    ESP.restart();
+  }
+
+  Serial.printf("\nWiFi OK. IP: %s\n", WiFi.localIP().toString().c_str());
+  digitalWrite(WIFI_LED_PIN, LOW);
+}
+
+void connectMQTT() {
+  mqttClient.setServer(cfgMqttBroker.c_str(), cfgMqttPort);
+  mqttClient.setCallback(mqttCallback);
+  Serial.print("MQTT connecting");
+  int attempts = 0;
+  while (!mqttClient.connected()) {
+    if (mqttClient.connect(cfgMqttClient.c_str(), cfgMqttUser.c_str(), cfgMqttPass.c_str())) {
+      Serial.println("\nMQTT OK.");
+      mqttClient.subscribe(cfgMqttTopic.c_str());
+      allOff(); breathingGreen(3); publishStatus();
+      return;
+    }
+    Serial.print("."); delay(500);
+    if (++attempts > 60) { Serial.println("\nMQTT failed!"); attempts = 0; }
+  }
+}
+
+void checkMQTTConnection() {
+  if (useMQTT && !mqttClient.connected()) { Serial.println("MQTT reconnecting..."); connectMQTT(); }
+}
+
+
+// =====================================================
+// 初始化
+// =====================================================
+
+void setup() {
+  Serial.begin(115200);
+  delay(500);
+
+  ledcAttach(RED_PIN, PWM_FREQ, PWM_RESOLUTION);
+  ledcAttach(YELLOW_PIN, PWM_FREQ, PWM_RESOLUTION);
+  ledcAttach(GREEN_PIN, PWM_FREQ, PWM_RESOLUTION);
+  pinMode(WIFI_LED_PIN, OUTPUT);
+  pinMode(BOOT_PIN, INPUT_PULLUP);
+  digitalWrite(WIFI_LED_PIN, HIGH);
+  allOff();
+
+  preferences.begin("ai-light", false);
+  useMQTT = (preferences.getUInt("comm_mode", 1) != 0);
+  loadConfig();
+
+  Serial.println();
+  Serial.println("=== AI-Light ===");
+  Serial.printf("Mode: %s\n", useMQTT ? "MQTT" : "BLE-only");
+  Serial.printf("WiFi: %s\n", cfgWifiSsid.length() > 0 ? cfgWifiSsid.c_str() : "(not set)");
+  Serial.printf("MQTT: %s\n", cfgMqttBroker.length() > 0 ? cfgMqttBroker.c_str() : "(not set)");
+
+  currentMode = "init";
+  modeStart = millis();
+
+  setupBLE();
+
+  if (useMQTT && isConfigComplete()) {
+    connectWiFi();
+    connectMQTT();
+  } else if (useMQTT) {
+    Serial.println("Config incomplete. Use BLE to configure.");
+  }
+
+  setMode("traffic");
+  Serial.println("Long press BOOT (3s) to switch mode.");
+}
+
+
+// =====================================================
+// 主循环
+// =====================================================
+
+void loop() {
+  updateStatusLed();
+  checkBootButton();
+
+  if (useMQTT) {
+    checkMQTTConnection();
+    mqttClient.loop();
+  }
+
+  autoTimeoutCheck();
+
+  if (currentMode == "busy") updateBusy();
+  else if (currentMode == "error") updateError();
+  else if (currentMode == "thinking") updateThinking();
+  else if (currentMode == "ai") updateAi();
+  else if (currentMode == "success") updateSuccess();
+  else if (currentMode == "traffic") updateTraffic();
+  else if (currentMode == "alarm") updateAlarm();
+  else if (currentMode == "init") updateInit();
+  else if (currentMode == "off") allOff();
+
+  delay(5);
+}

+ 6 - 0
go.sum

@@ -58,10 +58,12 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -69,12 +71,16 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
+golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.11 h1:loJ25fNOEhSXfHrpoGj91eCUThwdNX6u24rO1xnNteY=
 golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 317 - 0
web/ble-config.html

@@ -0,0 +1,317 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>AI-Light BLE Config</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#f0f2f5;min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:20px}
+.container{width:100%;max-width:420px}
+h1{font-size:22px;text-align:center;color:#333;margin-bottom:20px}
+h1 small{display:block;font-size:12px;color:#888;font-weight:normal;margin-top:4px}
+.card{background:#fff;border-radius:14px;padding:18px;margin-bottom:14px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
+.card h2{font-size:13px;color:#999;margin-bottom:14px;text-transform:uppercase;letter-spacing:1.5px;font-weight:600}
+label{display:block;font-size:12px;color:#666;margin:10px 0 5px;font-weight:600}
+input,select{width:100%;padding:11px 12px;border:1.5px solid #e0e0e0;border-radius:10px;font-size:14px;outline:none;transition:border .2s;background:#fafafa}
+input:focus,select:focus{border-color:#4CAF50;background:#fff}
+.row{display:flex;gap:10px}
+.row>div{flex:1}
+.btn{display:block;width:100%;padding:14px;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;transition:all .2s;letter-spacing:.5px}
+.btn:active{transform:scale(.98)}
+.btn-connect{background:#2196F3;color:#fff}
+.btn-connect.connected{background:#f44336}
+.btn-save{background:#4CAF50;color:#fff;margin-top:10px}
+.btn-restart{background:#ff9800;color:#fff;margin-top:10px}
+.btn:disabled{opacity:.5;cursor:not-allowed}
+.status-bar{padding:12px 16px;border-radius:10px;font-size:13px;font-weight:500;margin-bottom:14px;text-align:center}
+.status-bar.disconnected{background:#ffebee;color:#c62828}
+.status-bar.connected{background:#e8f5e9;color:#2e7d32}
+.status-bar.connecting{background:#fff3e0;color:#e65100}
+.badge{display:inline-block;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:700}
+.badge-ok{background:#e8f5e9;color:#2e7d32}
+.badge-err{background:#ffebee;color:#c62828}
+.status-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid #f5f5f5}
+.status-row:last-child{border:none}
+.status-label{font-size:13px;color:#999}
+.status-value{font-size:13px;color:#333;font-weight:600}
+.log{background:#263238;color:#a5d6a7;border-radius:10px;padding:12px;font-family:'SF Mono',monospace;font-size:11px;max-height:120px;overflow-y:auto;line-height:1.6;word-break:break-all}
+.hidden{display:none}
+.mode-btns{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
+.mode-btn{padding:10px 4px;border:1.5px solid #e0e0e0;border-radius:10px;background:#fff;font-size:12px;font-weight:600;cursor:pointer;text-align:center;transition:all .15s}
+.mode-btn:hover{border-color:#4CAF50;background:#f1f8e9}
+.mode-btn:active{transform:scale(.95)}
+.mode-btn.active{border-color:#4CAF50;background:#4CAF50;color:#fff}
+</style>
+</head>
+<body>
+<div class="container">
+  <h1>AI-Light<small>Web Bluetooth Configuration</small></h1>
+
+  <!-- Connection -->
+  <div id="conn-bar" class="status-bar disconnected">Not connected</div>
+  <button id="btn-connect" class="btn btn-connect" onclick="toggleConnect()">Connect</button>
+
+  <!-- Status (shown after connect) -->
+  <div id="sec-status" class="card hidden">
+    <h2>Device Status</h2>
+    <div class="status-row"><span class="status-label">WiFi</span><span class="status-value" id="s-wifi">-</span></div>
+    <div class="status-row"><span class="status-label">MQTT</span><span class="status-value" id="s-mqtt">-</span></div>
+    <div class="status-row"><span class="status-label">Mode</span><span class="status-value" id="s-mode">-</span></div>
+    <div class="status-row"><span class="status-label">Comm</span><span class="status-value" id="s-comm">-</span></div>
+  </div>
+
+  <!-- Light Mode (shown after connect) -->
+  <div id="sec-mode" class="card hidden">
+    <h2>Light Mode</h2>
+    <div class="mode-btns">
+      <div class="mode-btn" onclick="setMode('traffic')">Traffic</div>
+      <div class="mode-btn" onclick="setMode('thinking')">Thinking</div>
+      <div class="mode-btn" onclick="setMode('ai')">AI</div>
+      <div class="mode-btn" onclick="setMode('busy')">Busy</div>
+      <div class="mode-btn" onclick="setMode('success')">Success</div>
+      <div class="mode-btn" onclick="setMode('error')">Error</div>
+      <div class="mode-btn" onclick="setMode('alarm')">Alarm</div>
+      <div class="mode-btn" onclick="setMode('init')">Init</div>
+      <div class="mode-btn" onclick="setMode('off')">Off</div>
+    </div>
+  </div>
+
+  <!-- WiFi Config -->
+  <div id="sec-wifi" class="card hidden">
+    <h2>WiFi</h2>
+    <label>SSID</label>
+    <input id="f-ssid" placeholder="WiFi name">
+    <label>Password</label>
+    <input id="f-pass" type="password" placeholder="WiFi password">
+  </div>
+
+  <!-- MQTT Config -->
+  <div id="sec-mqtt" class="card hidden">
+    <h2>MQTT</h2>
+    <label>Broker</label>
+    <input id="f-broker" placeholder="192.168.1.100">
+    <div class="row">
+      <div><label>Port</label><input id="f-port" type="number" value="1883"></div>
+      <div><label>Client ID</label><input id="f-client" value="AI-Light"></div>
+    </div>
+    <label>Username</label>
+    <input id="f-muser" placeholder="(optional)">
+    <label>Password</label>
+    <input id="f-mpass" type="password" placeholder="(optional)">
+    <label>Subscribe Topic</label>
+    <input id="f-topic" placeholder="opencode/status">
+    <label>Status Topic</label>
+    <input id="f-stopic" placeholder="openCodeLight/status">
+  </div>
+
+  <!-- Actions (shown after connect) -->
+  <div id="sec-actions" class="hidden">
+    <button class="btn btn-save" onclick="saveConfig()">Save & Restart</button>
+    <button class="btn btn-restart" onclick="restartDevice()">Restart</button>
+  </div>
+
+  <!-- Log -->
+  <div id="sec-log" class="card hidden">
+    <h2>Log</h2>
+    <div id="log" class="log"></div>
+  </div>
+</div>
+
+<script>
+const SERVICE_UUID = 'b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001';
+const MODE_UUID = 'b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001';
+const CONFIG_UUID = 'b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001';
+
+let device = null;
+let modeChar = null;
+let configChar = null;
+let connected = false;
+
+const $ = id => document.getElementById(id);
+const logEl = $('log');
+
+function log(msg) {
+  const t = new Date().toLocaleTimeString();
+  logEl.innerHTML += `<div>[${t}] ${msg}</div>`;
+  logEl.scrollTop = logEl.scrollHeight;
+}
+
+function showSections(show) {
+  ['sec-status','sec-mode','sec-wifi','sec-mqtt','sec-actions','sec-log'].forEach(id => {
+    $(id).classList.toggle('hidden', !show);
+  });
+}
+
+function setStatus(text, type) {
+  const bar = $('conn-bar');
+  bar.textContent = text;
+  bar.className = 'status-bar ' + type;
+}
+
+async function toggleConnect() {
+  if (connected) {
+    if (device && device.gatt.connected) device.gatt.disconnect();
+    return;
+  }
+
+  try {
+    setStatus('Scanning...', 'connecting');
+    log('Requesting BLE device...');
+
+    device = await navigator.bluetooth.requestDevice({
+      filters: [{ name: 'AI-Light' }],
+      optionalServices: [SERVICE_UUID]
+    });
+
+    log('Device found: ' + device.name);
+    setStatus('Connecting...', 'connecting');
+
+    device.addEventListener('gattserverdisconnected', onDisconnected);
+
+    const server = await device.gatt.connect();
+    log('GATT connected');
+
+    const service = await server.getPrimaryService(SERVICE_UUID);
+    log('Service found');
+
+    modeChar = await service.getCharacteristic(MODE_UUID);
+    configChar = await service.getCharacteristic(CONFIG_UUID);
+    log('Characteristics found');
+
+    modeChar.addEventListener('characteristicvaluechanged', onModeChanged);
+    await modeChar.startNotifications();
+    log('Mode notifications enabled');
+
+    connected = true;
+    setStatus('Connected: ' + device.name, 'connected');
+    $('btn-connect').textContent = 'Disconnect';
+    $('btn-connect').classList.add('connected');
+    showSections(true);
+
+    await readConfig();
+    await readMode();
+
+  } catch (err) {
+    log('Error: ' + err.message);
+    setStatus('Connection failed', 'disconnected');
+    connected = false;
+    showSections(false);
+  }
+}
+
+function onDisconnected() {
+  connected = false;
+  modeChar = null;
+  configChar = null;
+  setStatus('Disconnected', 'disconnected');
+  $('btn-connect').textContent = 'Connect';
+  $('btn-connect').classList.remove('connected');
+  showSections(false);
+  log('Device disconnected');
+}
+
+function onModeChanged(event) {
+  const decoder = new TextDecoder();
+  const mode = decoder.decode(event.target.value).trim();
+  $('s-mode').textContent = mode;
+  log('Mode: ' + mode);
+  updateModeButtons(mode);
+}
+
+function updateModeButtons(mode) {
+  document.querySelectorAll('.mode-btn').forEach(btn => {
+    btn.classList.toggle('active', btn.textContent.toLowerCase() === mode);
+  });
+}
+
+async function readMode() {
+  try {
+    const val = await modeChar.readValue();
+    const decoder = new TextDecoder();
+    const mode = decoder.decode(val).trim();
+    $('s-mode').textContent = mode;
+    updateModeButtons(mode);
+    log('Current mode: ' + mode);
+  } catch (err) {
+    log('Read mode error: ' + err.message);
+  }
+}
+
+async function readConfig() {
+  try {
+    const val = await configChar.readValue();
+    const decoder = new TextDecoder();
+    const json = decoder.decode(val);
+    log('Config loaded');
+    const cfg = JSON.parse(json);
+
+    $('f-ssid').value = cfg.wifi_ssid || '';
+    $('f-broker').value = cfg.mqtt_broker || '';
+    $('f-port').value = cfg.mqtt_port || 1883;
+    $('f-client').value = cfg.mqtt_client || 'AI-Light';
+    $('f-muser').value = cfg.mqtt_user || '';
+    $('f-topic').value = cfg.mqtt_topic || '';
+    $('f-stopic').value = cfg.mqtt_status || '';
+
+    $('s-wifi').innerHTML = cfg.wifi_ssid ? '<span class="badge badge-ok">' + cfg.wifi_ssid + '</span>' : '<span class="badge badge-err">Not configured</span>';
+    $('s-mqtt').innerHTML = cfg.mqtt_broker ? '<span class="badge badge-ok">' + cfg.mqtt_broker + '</span>' : '<span class="badge badge-err">Not configured</span>';
+    $('s-comm').textContent = cfg.comm_mode === 1 ? 'MQTT' : 'BLE-only';
+
+  } catch (err) {
+    log('Read config error: ' + err.message);
+  }
+}
+
+async function setMode(mode) {
+  if (!modeChar) return;
+  try {
+    const encoder = new TextEncoder();
+    await modeChar.writeValue(encoder.encode(mode));
+    log('Set mode: ' + mode);
+  } catch (err) {
+    log('Set mode error: ' + err.message);
+  }
+}
+
+async function saveConfig() {
+  if (!configChar) return;
+  const cfg = {
+    wifi_ssid: $('f-ssid').value,
+    wifi_pass: $('f-pass').value,
+    mqtt_broker: $('f-broker').value,
+    mqtt_port: parseInt($('f-port').value) || 1883,
+    mqtt_user: $('f-muser').value,
+    mqtt_pass: $('f-mpass').value,
+    mqtt_client: $('f-client').value,
+    mqtt_topic: $('f-topic').value,
+    mqtt_status: $('f-stopic').value
+  };
+
+  try {
+    const encoder = new TextEncoder();
+    await configChar.writeValue(encoder.encode(JSON.stringify(cfg)));
+    log('Config saved! Device will restart...');
+    alert('Config saved! Device will restart.');
+  } catch (err) {
+    log('Save config error: ' + err.message);
+    alert('Error: ' + err.message);
+  }
+}
+
+async function restartDevice() {
+  if (!confirm('Restart device?')) return;
+  if (!configChar) return;
+  try {
+    const encoder = new TextEncoder();
+    await configChar.writeValue(encoder.encode('{"restart":true}'));
+    log('Restart command sent');
+  } catch (err) {
+    log('Restart error: ' + err.message);
+  }
+}
+
+showSections(false);
+</script>
+</body>
+</html>