9 次代码提交 88561522b3 ... b3eb2aed4f

作者 SHA1 备注 提交日期
  moki b3eb2aed4f 修复bug 1 周之前
  moki dc3f5c226a ble文档 1 周之前
  moki e771a0d16a 防抖加入 1 周之前
  moki 6fb7f7a881 删除独立固件 1 周之前
  moki f11f5a8ed0 通过web ble进行配置 1 周之前
  moki 2c1dfa2f86 数据脱敏 1 周之前
  moki f3ab728cee Merge remote-tracking branch 'origin/master' 1 周之前
  moki 32d1f3a1f4 添加蓝牙支持 2 周之前
  moki 77c0ef0b9c 移除mac支持 2 周之前

+ 4 - 6
README.md

@@ -11,7 +11,7 @@ OpenCode 状态监控工具,支持实时监控多个 OpenCode 实例的状态
 - 💾 **配置管理** - 使用 SQLite 存储 MQTT 和 BLE 配置
 - 🌐 **HTTP API** - 提供 RESTful API 接口管理配置(详见 [API 文档](docs/api.md))
 - 🔌 **WebSocket** - 支持通过 WebSocket 实时推送状态到网页(详见 [API 文档](docs/api.md))
-- 🖥️ **跨平台** - 支持 Linux、macOS、Windows
+- 🖥️ **跨平台** - 支持 Linux、Windows
 
 ## 安装
 
@@ -21,8 +21,6 @@ OpenCode 状态监控工具,支持实时监控多个 OpenCode 实例的状态
 
 - `opencode-monitor-linux-amd64` - Linux x86_64
 - `opencode-monitor-linux-arm64` - Linux ARM64
-- `opencode-monitor-darwin-amd64` - macOS Intel
-- `opencode-monitor-darwin-arm64` - macOS Apple Silicon
 - `opencode-monitor-windows-amd64.exe` - Windows x86_64
 - `opencode-monitor-windows-arm64.exe` - Windows ARM64
 
@@ -191,9 +189,9 @@ BLE 中继已嵌入 Go 二进制(使用 `make build-with-ble` 构建时),
 ./opencode-monitor config ble set [选项]
 
 选项:
-  --device          蓝牙设备名称 (默认 "AI-Light")
-  --service-uuid    BLE 服务 UUID (默认 "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001")
-  --char-uuid       BLE 特征 UUID (默认 "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001")
+  --device          蓝牙设备名称 (必填)
+  --service-uuid    BLE 服务 UUID (必填)
+  --char-uuid       BLE 特征 UUID (必填)
   --enabled         是否启用 (true/false, 默认 true)
 ```
 

+ 15 - 9
cmd/monitor/main.go

@@ -399,9 +399,9 @@ func printConfigUsage() {
 	fmt.Println("  --enabled         是否启用 (true/false)")
 	fmt.Println("")
 	fmt.Println("BLE 选项:")
-	fmt.Println("  --device          蓝牙设备名称 (默认: AI-Light)")
-	fmt.Println("  --service-uuid    BLE 服务 UUID")
-	fmt.Println("  --char-uuid       BLE 特征 UUID")
+	fmt.Println("  --device          蓝牙设备名称 (必填)")
+	fmt.Println("  --service-uuid    BLE 服务 UUID (必填)")
+	fmt.Println("  --char-uuid       BLE 特征 UUID (必填)")
 	fmt.Println("  --enabled         是否启用 (true/false)")
 	fmt.Println("")
 	fmt.Println("全局选项:")
@@ -499,12 +499,18 @@ func runBleConfig(db *database.DB, args []string) {
 
 	case "set":
 		fs := flag.NewFlagSet("config ble set", flag.ExitOnError)
-		deviceName := fs.String("device", "AI-Light", "蓝牙设备名称")
-		serviceUUID := fs.String("service-uuid", "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001", "BLE 服务 UUID")
-		charUUID := fs.String("char-uuid", "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001", "BLE 特征 UUID")
+		deviceName := fs.String("device", "", "蓝牙设备名称 (必填)")
+		serviceUUID := fs.String("service-uuid", "", "BLE 服务 UUID (必填)")
+		charUUID := fs.String("char-uuid", "", "BLE 特征 UUID (必填)")
 		enabled := fs.Bool("enabled", true, "是否启用")
 		fs.Parse(args[1:])
 
+		if *deviceName == "" || *serviceUUID == "" || *charUUID == "" {
+			fmt.Println("错误: --device, --service-uuid, --char-uuid 为必填参数")
+			fs.Usage()
+			return
+		}
+
 		cfg := &database.BLEConfig{
 			DeviceName:  *deviceName,
 			ServiceUUID: *serviceUUID,
@@ -553,9 +559,9 @@ func printBleConfigUsage() {
 	fmt.Println("  delete <id>       删除 BLE 配置")
 	fmt.Println("")
 	fmt.Println("选项:")
-	fmt.Println("  --device          蓝牙设备名称 (默认: AI-Light)")
-	fmt.Println("  --service-uuid    BLE 服务 UUID")
-	fmt.Println("  --char-uuid       BLE 特征 UUID")
+	fmt.Println("  --device          蓝牙设备名称 (必填)")
+	fmt.Println("  --service-uuid    BLE 服务 UUID (必填)")
+	fmt.Println("  --char-uuid       BLE 特征 UUID (必填)")
 	fmt.Println("  --enabled         是否启用 (true/false)")
 }
 

+ 397 - 0
docs/BLE_API.md

@@ -0,0 +1,397 @@
+# AI-Light BLE 接口文档
+
+## 设备信息
+
+| 项目 | 值 |
+|------|-----|
+| 设备名称 | `AI-Light` |
+| Service UUID | `b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001` |
+
+### BLE 特征
+
+| 特征 | UUID | 属性 | 说明 |
+|------|------|------|------|
+| Mode | `b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001` | READ / WRITE / NOTIFY | 灯效模式控制 |
+| Config | `b8b7e003-7a6b-4f4f-9a8b-11c0ffee0001` | READ / WRITE | WiFi/MQTT 配置 |
+
+## Mode 特征 - 灯效控制
+
+### 支持的模式
+
+| 模式 | 说明 | 灯效 |
+|------|------|------|
+| `init` | 初始化状态 | 三色一起呼吸 |
+| `thinking` | 思考中 | 绿→黄→红 连贯跑马灯 |
+| `ai` | AI 处理中 | 三色柔和波浪 |
+| `busy` | 忙碌状态 | 黄灯淡入淡出 |
+| `success` | 成功状态 | 绿灯常亮 |
+| `error` | 错误状态 | 红灯快速闪烁 |
+| `alarm` | 警告状态 | 红黄交替警灯 |
+| `traffic` | 交通灯模式 | 绿→黄→红 循环 |
+| `off` | 关闭 | 全灭 |
+| `red` | 红灯 | 红灯常亮 |
+| `yellow` | 黄灯 | 黄灯常亮 |
+| `green` | 绿灯 | 绿灯常亮 |
+| `idle` | 空闲(自动转为 traffic) | 同 traffic |
+
+### 行为说明
+
+- **重复模式忽略**:如果推送的模式与当前模式相同,设备会自动忽略,不做任何操作
+- **超时规则**:普通模式运行 **5分钟** → 自动进入 `traffic`;`traffic` 运行 **10分钟** → 自动进入 `off`
+- **通知**:模式变化时会通过 NOTIFY 特征推送当前模式
+
+## Config 特征 - WiFi/MQTT 配置
+
+### 读取配置
+
+读取 Config 特征返回当前配置 JSON(不含密码):
+
+```json
+{
+  "wifi_ssid": "MyNetwork",
+  "mqtt_broker": "192.168.1.100",
+  "mqtt_port": 1883,
+  "mqtt_user": "user",
+  "mqtt_client": "AI-Light",
+  "mqtt_topic": "opencode/status",
+  "mqtt_status": "openCodeLight/status",
+  "comm_mode": 1
+}
+```
+
+### 写入配置
+
+向 Config 特征写入 JSON 配置,设备自动保存到 NVS 并重启:
+
+```json
+{
+  "wifi_ssid": "MyNetwork",
+  "wifi_pass": "password123",
+  "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"
+}
+```
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+|------|------|------|--------|------|
+| wifi_ssid | string | 否 | - | WiFi SSID |
+| wifi_pass | string | 否 | - | WiFi 密码 |
+| mqtt_broker | string | 否 | - | MQTT Broker 地址 |
+| mqtt_port | number | 否 | 1883 | MQTT 端口 |
+| mqtt_user | string | 否 | - | MQTT 用户名 |
+| mqtt_pass | string | 否 | - | MQTT 密码 |
+| mqtt_client | string | 否 | AI-Light | MQTT Client ID |
+| mqtt_topic | string | 否 | opencode/status | 订阅主题 |
+| mqtt_status | string | 否 | openCodeLight/status | 状态发布主题 |
+
+**注意**:所有字段都是可选的,只更新提供的字段,未提供的字段保持原值。
+
+## 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})
+	configCharUUID = bluetooth.NewUUID([16]byte{0xb8, 0xb7, 0xe0, 0x03, 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})
+	configCharUUID = bluetooth.NewUUID([16]byte{0xb8, 0xb7, 0xe0, 0x03, 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
+}
+
+// SetConfig 写入 WiFi/MQTT 配置
+func SetConfig(configJSON 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{configCharUUID})
+	if err != nil || len(characteristics) == 0 {
+		return fmt.Errorf("config characteristic not found")
+	}
+
+	_, err = characteristics[0].WriteString(configJSON)
+	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")
+	
+	// 配置 WiFi/MQTT
+	ailight.SetConfig(`{
+		"wifi_ssid": "MyNetwork",
+		"wifi_pass": "password123",
+		"mqtt_broker": "192.168.1.100",
+		"mqtt_port": 1883,
+		"mqtt_client": "AI-Light",
+		"mqtt_topic": "opencode/status"
+	}`)
+}
+```
+
+## 平台支持
+
+| 平台 | 支持情况 | 备注 |
+|------|----------|------|
+| Linux | ✅ 完整支持 | 需要 BlueZ |
+| Windows | ⚠️ 部分支持 | 需要额外配置 |
+| Raspberry Pi | ✅ 完整支持 | 推荐用于部署 |
+
+## 注意事项
+
+1. **连接稳定性**:每次发送指令需要重新扫描和连接,建议封装连接池
+2. **超时处理**:扫描和连接都设置了 10 秒超时
+3. **并发控制**:BLE 连接不支持并发,需要加锁
+4. **重连机制**:建议实现自动重连逻辑

+ 86 - 25
docs/api.md

@@ -22,7 +22,29 @@ GET /api/health
 }
 ```
 
-### 2. 获取所有 MQTT 配置
+### 2. 获取所有客户端状态
+
+```
+GET /api/clients
+```
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "success",
+  "data": [
+    {
+      "port": 4096,
+      "status": "工作中",
+      "code": "busy",
+      "timestamp": "2026-06-03T14:30:00Z"
+    }
+  ]
+}
+```
+
+### 3. 获取所有 MQTT 配置
 
 ```
 GET /api/mqtt
@@ -47,7 +69,7 @@ GET /api/mqtt
 }
 ```
 
-### 3. 创建 MQTT 配置
+### 4. 创建 MQTT 配置
 
 ```
 POST /api/mqtt
@@ -91,7 +113,7 @@ POST /api/mqtt
 }
 ```
 
-### 4. 获取单个 MQTT 配置
+### 5. 获取单个 MQTT 配置
 
 ```
 GET /api/mqtt/:id
@@ -119,7 +141,7 @@ GET /api/mqtt/:id
 }
 ```
 
-### 5. 更新 MQTT 配置
+### 6. 更新 MQTT 配置
 
 ```
 PUT /api/mqtt/:id
@@ -159,7 +181,7 @@ PUT /api/mqtt/:id
 }
 ```
 
-### 6. 删除 MQTT 配置
+### 7. 删除 MQTT 配置
 
 ```
 DELETE /api/mqtt/:id
@@ -178,7 +200,7 @@ DELETE /api/mqtt/:id
 }
 ```
 
-### 7. 获取所有 BLE 配置
+### 8. 获取所有 BLE 配置
 
 ```
 GET /api/ble
@@ -201,7 +223,7 @@ GET /api/ble
 }
 ```
 
-### 8. 创建 BLE 配置
+### 9. 创建 BLE 配置
 
 ```
 POST /api/ble
@@ -217,12 +239,12 @@ POST /api/ble
 }
 ```
 
-| 字段 | 类型 | 必填 | 默认值 | 说明 |
-|------|------|------|--------|------|
-| device_name | string | 否 | AI-Light | BLE 设备名称 |
-| service_uuid | string | 否 | b8b7e001-... | BLE 服务 UUID |
-| char_uuid | string | 否 | b8b7e002-... | BLE 特征 UUID |
-| enabled | boolean | 否 | true | 是否启用 |
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| device_name | string |  | BLE 设备名称 |
+| service_uuid | string |  | BLE 服务 UUID |
+| char_uuid | string |  | BLE 特征 UUID |
+| enabled | boolean | 否 | 是否启用 (默认 true) |
 
 **响应示例:**
 ```json
@@ -239,7 +261,7 @@ POST /api/ble
 }
 ```
 
-### 9. 获取单个 BLE 配置
+### 10. 获取单个 BLE 配置
 
 ```
 GET /api/ble/:id
@@ -250,7 +272,7 @@ GET /api/ble/:id
 |------|------|------|
 | id | integer | 配置 ID |
 
-### 10. 更新 BLE 配置
+### 11. 更新 BLE 配置
 
 ```
 PUT /api/ble/:id
@@ -271,7 +293,7 @@ PUT /api/ble/:id
 }
 ```
 
-### 11. 删除 BLE 配置
+### 12. 删除 BLE 配置
 
 ```
 DELETE /api/ble/:id
@@ -300,8 +322,51 @@ DELETE /api/ble/:id
 
 # 方式2: 监控时同时启动 API 服务
 ./bin/opencode-monitor monitor --ports 4096 --api-addr :8080
+
+# 方式3: 启用 HTTPS (自签名证书)
+./bin/opencode-monitor serve --addr :8080 --tls
 ```
 
+## 子命令选项
+
+### serve 子命令
+
+| 选项 | 默认值 | 说明 |
+|------|--------|------|
+| `--addr` | `:8080` | 监听地址 |
+| `--db` | `./data/config.db` | 数据库路径 |
+| `--tls` | `false` | 启用 HTTPS (使用自签名证书) |
+| `--tls-cert` | `./data/tls/cert.pem` | TLS 证书文件路径 |
+| `--tls-key` | `./data/tls/key.pem` | TLS 私钥文件路径 |
+
+### monitor 子命令
+
+| 选项 | 默认值 | 说明 |
+|------|--------|------|
+| `--host` | `127.0.0.1` | 主机地址 |
+| `--ports` | - | 端口列表,逗号分隔 (如: 4096,4097,4098) |
+| `--scan` | - | 扫描端口范围 (如: 4096-4100) |
+| `--interval` | `1` | 动态扫描间隔(秒) |
+| `--api-addr` | - | API 服务地址 (如: :8080) |
+| `--db` | `./data/config.db` | 数据库路径 |
+| `--tls` | `false` | 启用 HTTPS (使用自签名证书) |
+| `--tls-cert` | `./data/tls/cert.pem` | TLS 证书文件路径 |
+| `--tls-key` | `./data/tls/key.pem` | TLS 私钥文件路径 |
+
+### config 子命令
+
+| 选项 | 默认值 | 说明 |
+|------|--------|------|
+| `--db` | `./data/config.db` | 数据库路径 |
+
+### version 子命令
+
+```bash
+./bin/opencode-monitor version
+```
+
+显示版本信息。
+
 ## 全局选项
 
 以下选项适用于 `monitor`、`serve`、`config` 三个子命令:
@@ -379,15 +444,11 @@ API 已启用 CORS,支持跨域请求。
 # 一键构建(打包 Python 脚本 + 嵌入 Go 二进制)
 make build-with-ble
 
-# 或手动两步
-scripts\build_ble_relay.bat
-go build -tags ble -o bin/opencode-monitor.exe ./cmd/monitor
-```
-
-### Python 依赖(仅开发时需要)
-
-```bash
-pip install -r scripts/requirements.txt
+# 或手动步骤
+python -m PyInstaller --onefile --name ble_relay --distpath bin scripts/ble_relay.py
+cp bin/ble_relay cmd/monitor/ble_relay
+go build -tags ble -o bin/opencode-monitor ./cmd/monitor
+rm -f cmd/monitor/ble_relay
 ```
 
 ### 状态映射

+ 68 - 11
firmware/README.md

@@ -2,23 +2,48 @@
 
 基于 **ESP32-C3 SuperMini** 的桌面状态灯固件,通过 BLE 蓝牙或 MQTT 接收状态指令,控制红绿灯挂件显示 AI 工作状态。
 
-## 固件版本
+## 固件
 
-| 版本 | 文件 | 通信方式 | 说明 |
-|------|------|----------|------|
-| BLE | `ble/ai_light.ino` | BLE 蓝牙 | 无需 WiFi,适合本地使用 |
-| MQTT v1 | `mqtt/ai_light.ino` | WiFi + MQTT | 远程控制 |
-| MQTT v2 | `mqtt/ai_light_v2.ino` | WiFi + MQTT | 改进版 |
+`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 字符串即可控制灯效:
 
 | 模式 | 灯效 | 典型场景 |
 |------|------|----------|
@@ -32,6 +57,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` 通过浏览器配置设备
+
 ## 硬件接线
 
 基于公共正极灯板:
@@ -53,10 +108,12 @@ ESP32 IO4   -> 220Ω -> 黄灯
 4. 打开 `.ino` 文件,点击 Upload
 5. 串口监视器波特率:`115200`
 
-## 依赖库(MQTT 版)
+## 依赖库
 
-- `WiFi.h`
+- `WiFi.h`(ESP32 内置)
 - `PubSubClient.h`
 - `ArduinoJson.h`
+- `BLEDevice.h`(ESP32 内置)
+- `Preferences.h`(ESP32 内置)
 
 通过 Arduino IDE → Sketch → Include Library → Manage Libraries 安装。

+ 651 - 0
firmware/ai_light.ino

@@ -0,0 +1,651 @@
+#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";
+
+  if (mode == currentMode) return;
+
+  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);
+}

+ 0 - 498
firmware/ble/ai_light.ino

@@ -1,498 +0,0 @@
-#include <BLEDevice.h>
-#include <BLEServer.h>
-#include <BLEUtils.h>
-#include <BLE2902.h>
-
-// =====================================================
-// ESP32-C3 SuperMini + 原玩具公共正极灯板:BLE 蓝牙控制增强版
-//
-// 接线方式:
-// ESP32 3.3V  -> 原灯板 + / 原电池正极
-// ESP32 IO2   -> 220Ω -> L1 控制点 = 绿灯
-// ESP32 IO3   -> 220Ω -> L2 控制点 = 红灯
-// ESP32 IO4   -> 220Ω -> L3 控制点 = 黄灯
-//
-// 注意:
-// 1. 原灯板 - / 原电池负极 第一版先不要接。
-// 2. 公共正极:GPIO LOW = 灯亮,GPIO HIGH = 灯灭。
-// 3. 默认开机模式:init
-// 4. 除 off、traffic 外,其他模式最多运行 5 分钟,然后自动进入 traffic。
-// 5. traffic 最多运行 10 分钟,然后自动 off。
-// =====================================================
-
-const char* BLE_DEVICE_NAME = "AI-Light";
-
-#define SERVICE_UUID        "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001"
-#define MODE_CHAR_UUID      "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001"
-
-// 你的实测:L1=绿灯,L2=红灯,L3=黄灯
-const int GREEN_PIN = 2;   // IO2 -> L1 绿灯
-const int RED_PIN = 3;     // IO3 -> L2 红灯
-const int YELLOW_PIN = 4;  // IO4 -> L3 黄灯
-const int WIFI_LED_PIN = 8; // IO8 -> 板载LED
-
-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;   // 5 分钟
-const unsigned long TRAFFIC_MODE_TIMEOUT_MS = 10UL * 60UL * 1000UL; // 10 分钟
-
-String currentMode = "init";
-unsigned long modeStart = 0;
-
-BLEServer* pServer = nullptr;
-BLECharacteristic* pModeCharacteristic = nullptr;
-bool deviceConnected = false;
-
-
-// =====================================================
-// 基础工具函数:公共正极反相输出
-// =====================================================
-
-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);
-  } else {
-    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 == "idle" ||
-    mode == "off"
-  );
-}
-
-void notifyMode() {
-  if (pModeCharacteristic) {
-    pModeCharacteristic->setValue(currentMode.c_str());
-    if (deviceConnected) {
-      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 changed to: ");
-  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();
-  }
-
-  notifyMode();
-}
-
-void autoTimeoutCheck() {
-  unsigned long elapsed = millis() - modeStart;
-
-  if (currentMode == "off") {
-    return;
-  }
-
-  if (currentMode == "traffic") {
-    if (elapsed >= TRAFFIC_MODE_TIMEOUT_MS) {
-      Serial.println("Traffic timeout -> off");
-      setMode("off");
-    }
-    return;
-  }
-
-  if (elapsed >= NORMAL_MODE_TIMEOUT_MS) {
-    Serial.println("Normal mode timeout -> traffic");
-    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);
-}
-
-// thinking:连贯跑马灯,按实物从上到下:L1绿 -> L2黄 -> L3红
-void updateThinking() {
-  unsigned long t = millis() - modeStart;
-  const unsigned long period = 1050;
-  unsigned long x = t % period;
-
-  int g = 0;
-  int y = 0;
-  int r = 0;
-
-  if (x < 350) {
-    g = map(x, 0, 350, GREEN_MAX, 70);
-    y = map(x, 0, 350, 20, YELLOW_MAX);
-    r = 0;
-  } 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);
-}
-
-// ai:柔和版跑马灯,比 thinking 更慢、更柔和、亮度低一点
-void updateAi() {
-  unsigned long t = millis() - modeStart;
-  const unsigned long period = 1800;
-  unsigned long x = t % period;
-
-  unsigned long gx = (x + 0) % period;
-  unsigned long yx = (x + period / 3) % period;
-  unsigned long rx = (x + 2 * period / 3) % period;
-
-  int g = triWave(gx, period, 150);
-  int y = triWave(yx, period, 140);
-  int r = triWave(rx, period, 170);
-
-  setOnly(r, y, g);
-}
-
-void updateSuccess() {
-  setOnly(0, 0, GREEN_MAX);
-}
-
-// alarm:红黄交替警灯,带短渐变
-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);
-  }
-}
-
-// traffic:绿灯变黄前绿闪;黄灯;红灯变绿前红闪
-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);
-    else g = 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);
-    else r = 0;
-    setOnly(r, 0, 0);
-  }
-}
-
-// init:三色一起呼吸
-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);
-}
-
-
-// =====================================================
-// BLE 回调
-// =====================================================
-
-class ServerCallbacks : public BLEServerCallbacks {
-  void onConnect(BLEServer* pServer) {
-    deviceConnected = true;
-    Serial.println("BLE client connected.");
-  }
-
-  void onDisconnect(BLEServer* pServer) {
-    deviceConnected = false;
-    Serial.println("BLE client disconnected. Restart advertising.");
-    BLEDevice::startAdvertising();
-  }
-};
-
-class ModeCharacteristicCallbacks : public BLECharacteristicCallbacks {
-  void onWrite(BLECharacteristic* pCharacteristic) {
-    String value = pCharacteristic->getValue();
-    value.trim();
-
-    Serial.print("BLE write: ");
-    Serial.println(value);
-
-    setMode(value);
-  }
-
-  void onRead(BLECharacteristic* pCharacteristic) {
-    pCharacteristic->setValue(currentMode.c_str());
-  }
-};
-
-
-// =====================================================
-// 初始化
-// =====================================================
-
-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);
-  digitalWrite(WIFI_LED_PIN, HIGH);
-
-  allOff();
-
-  currentMode = "init";
-  modeStart = millis();
-
-  Serial.println();
-  Serial.println("Power on. Default mode: init");
-  Serial.println("Common anode BLE enhanced version.");
-  Serial.println("BLE device name: AI-Light");
-
-  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 ModeCharacteristicCallbacks());
-  pModeCharacteristic->setValue(currentMode.c_str());
-  pModeCharacteristic->addDescriptor(new BLE2902());
-
-  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 started.");
-  Serial.println("Supported modes:");
-  Serial.println("init / thinking / ai / busy / success / error / alarm / traffic / off / red / yellow / green");
-}
-
-
-// =====================================================
-// 主循环
-// =====================================================
-
-unsigned long lastBleLedToggle = 0;
-bool bleLedState = false;
-
-void updateBleLed() {
-  if (deviceConnected) {
-    digitalWrite(WIFI_LED_PIN, LOW);
-  } else {
-    if (millis() - lastBleLedToggle >= 500) {
-      bleLedState = !bleLedState;
-      digitalWrite(WIFI_LED_PIN, bleLedState ? LOW : HIGH);
-      lastBleLedToggle = millis();
-    }
-  }
-}
-
-void loop() {
-  updateBleLed();
-  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);
-}

+ 0 - 677
firmware/mqtt/ai_light.ino

@@ -1,677 +0,0 @@
-#include <WiFi.h>
-#include <PubSubClient.h>
-#include <ArduinoJson.h>
-
-// =====================================================
-// ESP32-C3 SuperMini + 原玩具公共正极灯板:MQTT 蓝牙控制增强版
-//
-// 接线方式:
-// ESP32 3.3V  -> 原灯板 + / 原电池正极
-// ESP32 IO2   -> 220Ω -> L1 控制点 = 绿灯
-// ESP32 IO3   -> 220Ω -> L2 控制点 = 黄灯
-// ESP32 IO4   -> 220Ω -> L3 控制点 = 红灯
-//
-// 注意:
-// 1. 原灯板 - / 原电池负极 第一版先不要接。
-// 2. 公共正极:GPIO LOW = 灯亮,GPIO HIGH = 灯灭。
-// 3. 默认开机模式:init
-// 4. 除 off、traffic 外,其他模式最多运行 5 分钟,然后自动进入 traffic。
-// 5. traffic 最多运行 10 分钟,然后自动 off。
-// =====================================================
-
-// =====================================================
-// WiFi & MQTT 配置(请根据你的网络修改)
-// =====================================================
-#define WIFI_SSID        "MokiBox-IoT"
-#define WIFI_PASSWORD    "Moki@886."
-#define MQTT_BROKER      "47.92.50.210"
-#define MQTT_PORT        9883
-#define MQTT_USERNAME    "moki"
-#define MQTT_PASSWORD    "Moki@886."
-#define MQTT_CLIENT_ID   "AI-Light"
-#define MQTT_TOPIC       "opencode/status"
-#define MQTT_STATUS_TOPIC "openCodeLight/status"
-
-// 你的实测:L1=绿灯,L2=黄灯,L3=红灯
-const int GREEN_PIN = 2;   // IO2 -> L1 绿灯
-const int YELLOW_PIN = 3;  // IO3 -> L2 黄灯
-const int RED_PIN = 4;     // IO4 -> L3 红灯
-const int WIFI_LED_PIN = 8; // IO8 -> 板载LED
-
-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;   // 5 分钟
-const unsigned long TRAFFIC_MODE_TIMEOUT_MS = 10UL * 60UL * 1000UL; // 10 分钟
-
-String currentMode = "init";
-unsigned long modeStart = 0;
-
-WiFiClient wifiClient;
-PubSubClient mqttClient(wifiClient);
-
-
-// =====================================================
-// 基础工具函数:公共正极反相输出
-// =====================================================
-
-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);
-  } else {
-    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 blinkLed(int pin, int times = 3, int intervalMs = 200) {
-  for (int i = 0; i < times; i++) {
-    writeLed(pin, 200);
-    delay(intervalMs);
-    writeLed(pin, 0);
-    delay(intervalMs);
-  }
-}
-
-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() {
-  mqttClient.publish(MQTT_STATUS_TOPIC, currentMode.c_str(), true);
-}
-
-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 changed to: ");
-  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();
-}
-
-void autoTimeoutCheck() {
-  unsigned long elapsed = millis() - modeStart;
-
-  if (currentMode == "off") {
-    return;
-  }
-
-  if (currentMode == "traffic") {
-    if (elapsed >= TRAFFIC_MODE_TIMEOUT_MS) {
-      Serial.println("Traffic timeout -> off");
-      setMode("off");
-    }
-    return;
-  }
-
-  if (elapsed >= NORMAL_MODE_TIMEOUT_MS) {
-    Serial.println("Normal mode timeout -> traffic");
-    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);
-}
-
-// thinking:连贯跑马灯,按实物从上到下:L1绿 -> L2黄 -> L3红
-void updateThinking() {
-  unsigned long t = millis() - modeStart;
-  const unsigned long period = 1050;
-  unsigned long x = t % period;
-
-  int g = 0;
-  int y = 0;
-  int r = 0;
-
-  if (x < 350) {
-    g = map(x, 0, 350, GREEN_MAX, 70);
-    y = map(x, 0, 350, 20, YELLOW_MAX);
-    r = 0;
-  } 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);
-}
-
-// ai:柔和版跑马灯,比 thinking 更慢、更柔和、亮度低一点
-void updateAi() {
-  unsigned long t = millis() - modeStart;
-  const unsigned long period = 1800;
-  unsigned long x = t % period;
-
-  unsigned long gx = (x + 0) % period;
-  unsigned long yx = (x + period / 3) % period;
-  unsigned long rx = (x + 2 * period / 3) % period;
-
-  int g = triWave(gx, period, 150);
-  int y = triWave(yx, period, 140);
-  int r = triWave(rx, period, 170);
-
-  setOnly(r, y, g);
-}
-
-void updateSuccess() {
-  setOnly(0, 0, GREEN_MAX);
-}
-
-// alarm:红黄交替警灯,带短渐变
-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);
-  }
-}
-
-// traffic:绿灯变黄前绿闪;黄灯;红灯变绿前红闪
-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);
-    else g = 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);
-    else r = 0;
-    setOnly(r, 0, 0);
-  }
-}
-
-// init:三色一起呼吸
-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 updateCompletedFlash() {
-  unsigned long t = millis() - modeStart;
-  const int flashCount = 3;
-  const unsigned long flashDuration = 150;  // 每次闪烁150ms
-  const unsigned long totalDuration = flashCount * 2 * flashDuration;  // 3次闪烁总时长
-  
-  if (t >= totalDuration) {
-    // 闪烁完成,保持灭灯
-    setOnly(0, 0, 0);
-    return;
-  }
-  
-  // 计算当前闪烁状态
-  unsigned long flashPhase = t % (2 * flashDuration);
-  if (flashPhase < flashDuration) {
-    // 亮灯阶段
-    setOnly(0, 0, GREEN_MAX);
-  } else {
-    // 灭灯阶段
-    setOnly(0, 0, 0);
-  }
-}
-
-// 会话完成:绿灯呼吸闪烁(3次后自动切换到idle)
-void updateBreathing() {
-  unsigned long t = millis() - modeStart;
-  const unsigned long period = 2500;  // 2.5秒一个呼吸周期
-  const int breathCount = 3;  // 呼吸3次
-  const unsigned long totalDuration = breathCount * period;  // 总时长
-  
-  // 检查是否完成3次呼吸
-  if (t >= totalDuration) {
-    // 3次呼吸完成,自动切换到idle
-    setMode("idle");
-    return;
-  }
-  
-  unsigned long x = t % period;
-  
-  int brightness;
-  if (x < 800) {
-    // 吸入阶段:0 -> 最大
-    brightness = map(x, 0, 800, 0, GREEN_MAX);
-  } else if (x < 1200) {
-    // 保持阶段:最大亮度
-    brightness = GREEN_MAX;
-  } else if (x < 2000) {
-    // 呼出阶段:最大 -> 0
-    brightness = map(x, 1200, 2000, GREEN_MAX, 0);
-  } else {
-    // 停顿阶段:0亮度
-    brightness = 0;
-  }
-  
-  setOnly(0, 0, brightness);
-}
-
-
-// =====================================================
-// 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 message on ");
-  Serial.print(topic);
-  Serial.print(": ");
-  Serial.println(message);
-
-  JsonDocument doc;
-  DeserializationError error = deserializeJson(doc, message);
-
-  if (error) {
-    Serial.print("JSON parse failed, treating as raw mode: ");
-    Serial.println(error.c_str());
-    setMode(message);
-    return;
-  }
-
-  const char* code = doc["code"];
-  if (code) {
-    String codeStr = String(code);
-    
-    // 状态码映射到灯效
-    if (codeStr == "idle") {
-      setMode("idle");
-    } else if (codeStr == "busy" || codeStr == "running") {
-      setMode("busy");
-    } else if (codeStr == "retry" || codeStr == "permission") {
-      setMode("alarm");
-    } else if (codeStr == "pending") {
-      setMode("yellow");
-    } else if (codeStr == "reasoning") {
-      setMode("thinking");
-    } else if (codeStr == "using_tool") {
-      setMode("ai");
-    } else if (codeStr == "error") {
-      setMode("error");
-    } else {
-      // 直接使用原始模式名
-      setMode(codeStr);
-    }
-  } else {
-    Serial.println("MQTT message missing 'code' field");
-  }
-}
-
-unsigned long lastWifiLedToggle = 0;
-bool wifiLedState = false;
-
-void updateWifiLed() {
-  if (WiFi.status() == WL_CONNECTED) {
-    digitalWrite(WIFI_LED_PIN, LOW);
-  } else {
-    if (millis() - lastWifiLedToggle >= 500) {
-      wifiLedState = !wifiLedState;
-      digitalWrite(WIFI_LED_PIN, wifiLedState ? LOW : HIGH);
-      lastWifiLedToggle = millis();
-    }
-  }
-}
-
-void connectWiFi() {
-  Serial.print("Connecting to WiFi");
-  WiFi.mode(WIFI_STA);
-  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
-
-  int maxRetries = 5;
-  int attemptsPerRetry = 60;
-
-  for (int retry = 1; retry <= maxRetries; retry++) {
-    Serial.print("\nWiFi attempt ");
-    Serial.print(retry);
-    Serial.print("/");
-    Serial.println(maxRetries);
-
-    int attempts = 0;
-    while (WiFi.status() != WL_CONNECTED && attempts < attemptsPerRetry) {
-      delay(5);
-      setOnly(100, 100, 100);
-      updateWifiLed();
-      Serial.print(".");
-      attempts++;
-    }
-
-    if (WiFi.status() == WL_CONNECTED) {
-      break;
-    }
-
-    Serial.println("\nWiFi connection failed, retrying...");
-    delay(2000);
-  }
-
-  if (WiFi.status() != WL_CONNECTED) {
-    Serial.println("WiFi failed after 5 retries. Restarting...");
-    delay(1000);
-    ESP.restart();
-  }
-
-  Serial.println();
-  Serial.print("WiFi connected. IP: ");
-  Serial.println(WiFi.localIP());
-  digitalWrite(WIFI_LED_PIN, LOW);
-}
-
-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();
-}
-
-void connectMQTT() {
-  mqttClient.setServer(MQTT_BROKER, MQTT_PORT);
-  mqttClient.setCallback(mqttCallback);
-
-  Serial.print("Connecting to MQTT broker");
-  int attempts = 0;
-  bool ledState = false;
-  while (!mqttClient.connected()) {
-    ledState = !ledState;
-    if (ledState) {
-      setOnly(RED_MAX, 0, 0);
-    } else {
-      setOnly(0, YELLOW_MAX, 0);
-    }
-
-    if (mqttClient.connect(MQTT_CLIENT_ID, MQTT_USERNAME, MQTT_PASSWORD)) {
-      Serial.println();
-      Serial.println("MQTT connected.");
-
-      mqttClient.subscribe(MQTT_TOPIC);
-      Serial.print("Subscribed to: ");
-      Serial.println(MQTT_TOPIC);
-
-      allOff();
-      breathingGreen(3);
-      publishStatus();
-    } else {
-      Serial.print(".");
-      delay(500);
-      attempts++;
-      if (attempts > 60) {
-        Serial.println("\nMQTT connection failed!");
-        attempts = 0;
-      }
-    }
-  }
-}
-
-void checkMQTTConnection() {
-  if (!mqttClient.connected()) {
-    Serial.println("MQTT disconnected. 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);
-  digitalWrite(WIFI_LED_PIN, LOW);  // 初始状态关闭
-
-  allOff();
-
-  Serial.println();
-  Serial.println("Power on.");
-  Serial.println("Common anode MQTT enhanced version.");
-  Serial.print("Device name: ");
-  Serial.println(MQTT_CLIENT_ID);
-
-  currentMode = "init";
-  modeStart = millis();
-
-  connectWiFi();
-  connectMQTT();
-
-  setMode("traffic");
-
-  Serial.println("Supported modes:");
-  Serial.println("init / thinking / ai / busy / success / error / alarm / traffic / off / red / yellow / green");
-}
-
-
-// =====================================================
-// 主循环
-// =====================================================
-
-void loop() {
-  updateWifiLed();
-  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);
-}

+ 0 - 677
firmware/mqtt/ai_light_v2.ino

@@ -1,677 +0,0 @@
-#include <WiFi.h>
-#include <PubSubClient.h>
-#include <ArduinoJson.h>
-
-// =====================================================
-// ESP32-C3 SuperMini + 原玩具公共正极灯板:MQTT 蓝牙控制增强版 V2
-//
-// 接线方式(V2 版本:红黄互换 + 黄绿互换):
-// ESP32 3.3V  -> 原灯板 + / 原电池正极
-// ESP32 IO2   -> 220Ω -> L1 控制点 = 黄灯
-// ESP32 IO3   -> 220Ω -> L2 控制点 = 红灯
-// ESP32 IO4   -> 220Ω -> L3 控制点 = 绿灯
-//
-// 注意:
-// 1. 原灯板 - / 原电池负极 第一版先不要接。
-// 2. 公共正极:GPIO LOW = 灯亮,GPIO HIGH = 灯灭。
-// 3. 默认开机模式:init
-// 4. 除 off、traffic 外,其他模式最多运行 5 分钟,然后自动进入 traffic。
-// 5. traffic 最多运行 10 分钟,然后自动 off。
-// =====================================================
-
-// =====================================================
-// WiFi & MQTT 配置(请根据你的网络修改)
-// =====================================================
-#define WIFI_SSID        "MokiBox-IoT"
-#define WIFI_PASSWORD    "Moki@886."
-#define MQTT_BROKER      "47.92.50.210"
-#define MQTT_PORT        9883
-#define MQTT_USERNAME    "moki"
-#define MQTT_PASSWORD    "Moki@886."
-#define MQTT_CLIENT_ID   "AI-Light"
-#define MQTT_TOPIC       "opencode/status"
-#define MQTT_STATUS_TOPIC "openCodeLight/status"
-
-// V2 版本:红黄互换 + 黄绿互换
-const int GREEN_PIN = 2;   // IO2 -> L1 绿灯
-const int RED_PIN = 3;     // IO3 -> L2 红灯
-const int YELLOW_PIN = 4;  // IO4 -> L3 黄灯
-const int WIFI_LED_PIN = 8; // IO8 -> 板载LED
-
-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;   // 5 分钟
-const unsigned long TRAFFIC_MODE_TIMEOUT_MS = 10UL * 60UL * 1000UL; // 10 分钟
-
-String currentMode = "init";
-unsigned long modeStart = 0;
-
-WiFiClient wifiClient;
-PubSubClient mqttClient(wifiClient);
-
-
-// =====================================================
-// 基础工具函数:公共正极反相输出
-// =====================================================
-
-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);
-  } else {
-    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 blinkLed(int pin, int times = 3, int intervalMs = 200) {
-  for (int i = 0; i < times; i++) {
-    writeLed(pin, 200);
-    delay(intervalMs);
-    writeLed(pin, 0);
-    delay(intervalMs);
-  }
-}
-
-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() {
-  mqttClient.publish(MQTT_STATUS_TOPIC, currentMode.c_str(), true);
-}
-
-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 changed to: ");
-  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();
-}
-
-void autoTimeoutCheck() {
-  unsigned long elapsed = millis() - modeStart;
-
-  if (currentMode == "off") {
-    return;
-  }
-
-  if (currentMode == "traffic") {
-    if (elapsed >= TRAFFIC_MODE_TIMEOUT_MS) {
-      Serial.println("Traffic timeout -> off");
-      setMode("off");
-    }
-    return;
-  }
-
-  if (elapsed >= NORMAL_MODE_TIMEOUT_MS) {
-    Serial.println("Normal mode timeout -> traffic");
-    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);
-}
-
-// thinking:连贯跑马灯,按实物从上到下:L1绿 -> L2黄 -> L3红
-void updateThinking() {
-  unsigned long t = millis() - modeStart;
-  const unsigned long period = 1050;
-  unsigned long x = t % period;
-
-  int g = 0;
-  int y = 0;
-  int r = 0;
-
-  if (x < 350) {
-    g = map(x, 0, 350, GREEN_MAX, 70);
-    y = map(x, 0, 350, 20, YELLOW_MAX);
-    r = 0;
-  } 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);
-}
-
-// ai:柔和版跑马灯,比 thinking 更慢、更柔和、亮度低一点
-void updateAi() {
-  unsigned long t = millis() - modeStart;
-  const unsigned long period = 1800;
-  unsigned long x = t % period;
-
-  unsigned long gx = (x + 0) % period;
-  unsigned long yx = (x + period / 3) % period;
-  unsigned long rx = (x + 2 * period / 3) % period;
-
-  int g = triWave(gx, period, 150);
-  int y = triWave(yx, period, 140);
-  int r = triWave(rx, period, 170);
-
-  setOnly(r, y, g);
-}
-
-void updateSuccess() {
-  setOnly(0, 0, GREEN_MAX);
-}
-
-// alarm:红黄交替警灯,带短渐变
-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);
-  }
-}
-
-// traffic:绿灯变黄前绿闪;黄灯;红灯变绿前红闪
-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);
-    else g = 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);
-    else r = 0;
-    setOnly(r, 0, 0);
-  }
-}
-
-// init:三色一起呼吸
-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 updateCompletedFlash() {
-  unsigned long t = millis() - modeStart;
-  const int flashCount = 3;
-  const unsigned long flashDuration = 150;  // 每次闪烁150ms
-  const unsigned long totalDuration = flashCount * 2 * flashDuration;  // 3次闪烁总时长
-  
-  if (t >= totalDuration) {
-    // 闪烁完成,保持灭灯
-    setOnly(0, 0, 0);
-    return;
-  }
-  
-  // 计算当前闪烁状态
-  unsigned long flashPhase = t % (2 * flashDuration);
-  if (flashPhase < flashDuration) {
-    // 亮灯阶段
-    setOnly(0, 0, GREEN_MAX);
-  } else {
-    // 灭灯阶段
-    setOnly(0, 0, 0);
-  }
-}
-
-// 会话完成:绿灯呼吸闪烁(3次后自动切换到idle)
-void updateBreathing() {
-  unsigned long t = millis() - modeStart;
-  const unsigned long period = 2500;  // 2.5秒一个呼吸周期
-  const int breathCount = 3;  // 呼吸3次
-  const unsigned long totalDuration = breathCount * period;  // 总时长
-  
-  // 检查是否完成3次呼吸
-  if (t >= totalDuration) {
-    // 3次呼吸完成,自动切换到idle
-    setMode("idle");
-    return;
-  }
-  
-  unsigned long x = t % period;
-  
-  int brightness;
-  if (x < 800) {
-    // 吸入阶段:0 -> 最大
-    brightness = map(x, 0, 800, 0, GREEN_MAX);
-  } else if (x < 1200) {
-    // 保持阶段:最大亮度
-    brightness = GREEN_MAX;
-  } else if (x < 2000) {
-    // 呼出阶段:最大 -> 0
-    brightness = map(x, 1200, 2000, GREEN_MAX, 0);
-  } else {
-    // 停顿阶段:0亮度
-    brightness = 0;
-  }
-  
-  setOnly(0, 0, brightness);
-}
-
-
-// =====================================================
-// 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 message on ");
-  Serial.print(topic);
-  Serial.print(": ");
-  Serial.println(message);
-
-  JsonDocument doc;
-  DeserializationError error = deserializeJson(doc, message);
-
-  if (error) {
-    Serial.print("JSON parse failed, treating as raw mode: ");
-    Serial.println(error.c_str());
-    setMode(message);
-    return;
-  }
-
-  const char* code = doc["code"];
-  if (code) {
-    String codeStr = String(code);
-    
-    // 状态码映射到灯效
-    if (codeStr == "idle") {
-      setMode("idle");
-    } else if (codeStr == "busy" || codeStr == "running") {
-      setMode("busy");
-    } else if (codeStr == "retry" || codeStr == "permission") {
-      setMode("alarm");
-    } else if (codeStr == "pending") {
-      setMode("yellow");
-    } else if (codeStr == "reasoning") {
-      setMode("thinking");
-    } else if (codeStr == "using_tool") {
-      setMode("ai");
-    } else if (codeStr == "error") {
-      setMode("error");
-    } else {
-      // 直接使用原始模式名
-      setMode(codeStr);
-    }
-  } else {
-    Serial.println("MQTT message missing 'code' field");
-  }
-}
-
-unsigned long lastWifiLedToggle = 0;
-bool wifiLedState = false;
-
-void updateWifiLed() {
-  if (WiFi.status() == WL_CONNECTED) {
-    digitalWrite(WIFI_LED_PIN, LOW);
-  } else {
-    if (millis() - lastWifiLedToggle >= 500) {
-      wifiLedState = !wifiLedState;
-      digitalWrite(WIFI_LED_PIN, wifiLedState ? LOW : HIGH);
-      lastWifiLedToggle = millis();
-    }
-  }
-}
-
-void connectWiFi() {
-  Serial.print("Connecting to WiFi");
-  WiFi.mode(WIFI_STA);
-  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
-
-  int maxRetries = 5;
-  int attemptsPerRetry = 60;
-
-  for (int retry = 1; retry <= maxRetries; retry++) {
-    Serial.print("\nWiFi attempt ");
-    Serial.print(retry);
-    Serial.print("/");
-    Serial.println(maxRetries);
-
-    int attempts = 0;
-    while (WiFi.status() != WL_CONNECTED && attempts < attemptsPerRetry) {
-      delay(5);
-      setOnly(100, 100, 100);
-      updateWifiLed();
-      Serial.print(".");
-      attempts++;
-    }
-
-    if (WiFi.status() == WL_CONNECTED) {
-      break;
-    }
-
-    Serial.println("\nWiFi connection failed, retrying...");
-    delay(2000);
-  }
-
-  if (WiFi.status() != WL_CONNECTED) {
-    Serial.println("WiFi failed after 5 retries. Restarting...");
-    delay(1000);
-    ESP.restart();
-  }
-
-  Serial.println();
-  Serial.print("WiFi connected. IP: ");
-  Serial.println(WiFi.localIP());
-  digitalWrite(WIFI_LED_PIN, LOW);
-}
-
-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();
-}
-
-void connectMQTT() {
-  mqttClient.setServer(MQTT_BROKER, MQTT_PORT);
-  mqttClient.setCallback(mqttCallback);
-
-  Serial.print("Connecting to MQTT broker");
-  int attempts = 0;
-  bool ledState = false;
-  while (!mqttClient.connected()) {
-    ledState = !ledState;
-    if (ledState) {
-      setOnly(RED_MAX, 0, 0);
-    } else {
-      setOnly(0, YELLOW_MAX, 0);
-    }
-
-    if (mqttClient.connect(MQTT_CLIENT_ID, MQTT_USERNAME, MQTT_PASSWORD)) {
-      Serial.println();
-      Serial.println("MQTT connected.");
-
-      mqttClient.subscribe(MQTT_TOPIC);
-      Serial.print("Subscribed to: ");
-      Serial.println(MQTT_TOPIC);
-
-      allOff();
-      breathingGreen(3);
-      publishStatus();
-    } else {
-      Serial.print(".");
-      delay(500);
-      attempts++;
-      if (attempts > 60) {
-        Serial.println("\nMQTT connection failed!");
-        attempts = 0;
-      }
-    }
-  }
-}
-
-void checkMQTTConnection() {
-  if (!mqttClient.connected()) {
-    Serial.println("MQTT disconnected. 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);
-  digitalWrite(WIFI_LED_PIN, LOW);  // 初始状态关闭
-
-  allOff();
-
-  Serial.println();
-  Serial.println("Power on.");
-  Serial.println("Common anode MQTT enhanced version V2 (Red/Yellow + Yellow/Green swapped).");
-  Serial.print("Device name: ");
-  Serial.println(MQTT_CLIENT_ID);
-
-  currentMode = "init";
-  modeStart = millis();
-
-  connectWiFi();
-  connectMQTT();
-
-  setMode("traffic");
-
-  Serial.println("Supported modes:");
-  Serial.println("init / thinking / ai / busy / success / error / alarm / traffic / off / red / yellow / green");
-}
-
-
-// =====================================================
-// 主循环
-// =====================================================
-
-void loop() {
-  updateWifiLed();
-  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);
-}

+ 5 - 0
go.mod

@@ -5,15 +5,20 @@ go 1.21
 require (
 	github.com/eclipse/paho.mqtt.golang v1.4.3
 	github.com/gorilla/websocket v1.5.0
+	github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e
 	modernc.org/sqlite v1.24.0
 )
 
 require (
 	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/fatih/structs v1.1.0 // indirect
+	github.com/godbus/dbus/v5 v5.0.3 // indirect
 	github.com/google/uuid v1.3.0 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+	github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
 	github.com/mattn/go-isatty v0.0.16 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/sirupsen/logrus v1.6.0 // indirect
 	golang.org/x/mod v0.3.0 // indirect
 	golang.org/x/net v0.8.0 // indirect
 	golang.org/x/sync v0.1.0 // indirect

+ 34 - 0
go.sum

@@ -1,26 +1,50 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
 github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
+github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
+github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
+github.com/godbus/dbus/v5 v5.0.3 h1:ZqHaoEF7TBzh4jzPmqVhE/5A1z9of6orkAe5uHoAeME=
+github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
 github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e h1:1Sc4DqlgszKejMkjydCSq8zOKmF+hr8odAl5JoBZ+ec=
+github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e/go.mod h1:dMCjicU6vRBk34dqOmIZm0aod6gUwZXOXzBROqGous0=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3S+TUwg0tYQjid0q1txyNUJuuSmz8Kdk=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/suapapa/go_eddystone v1.3.1/go.mod h1:bXC11TfJOS+3g3q/Uzd7FKd5g62STQEfeEIhcKe4Qy8=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -29,15 +53,20 @@ golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 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=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+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=
@@ -46,12 +75,17 @@ 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 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
 golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
 lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
 modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=

+ 6 - 16
internal/api/api.go

@@ -326,14 +326,9 @@ func (s *Server) createBLEConfig(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if cfg.DeviceName == "" {
-		cfg.DeviceName = "AI-Light"
-	}
-	if cfg.ServiceUUID == "" {
-		cfg.ServiceUUID = "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001"
-	}
-	if cfg.CharUUID == "" {
-		cfg.CharUUID = "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001"
+	if cfg.DeviceName == "" || cfg.ServiceUUID == "" || cfg.CharUUID == "" {
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "device_name, service_uuid, char_uuid 不能为空"})
+		return
 	}
 
 	if err := s.db.SaveBLEConfig(&cfg); err != nil {
@@ -376,14 +371,9 @@ func (s *Server) updateBLEConfig(w http.ResponseWriter, r *http.Request, id int)
 
 	cfg.ID = id
 
-	if cfg.DeviceName == "" {
-		cfg.DeviceName = "AI-Light"
-	}
-	if cfg.ServiceUUID == "" {
-		cfg.ServiceUUID = "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001"
-	}
-	if cfg.CharUUID == "" {
-		cfg.CharUUID = "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001"
+	if cfg.DeviceName == "" || cfg.ServiceUUID == "" || cfg.CharUUID == "" {
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "device_name, service_uuid, char_uuid 不能为空"})
+		return
 	}
 
 	if err := s.db.SaveBLEConfig(&cfg); err != nil {

+ 66 - 0
internal/ble/ble.go

@@ -0,0 +1,66 @@
+package ble
+
+import (
+	"ai-status-light/internal/logger"
+)
+
+const (
+	DefaultDeviceName = "AI-Light"
+)
+
+var modePriority = map[string]int{
+	"error":    6,
+	"alarm":    5,
+	"thinking": 4,
+	"busy":     3,
+	"ai":       2,
+	"traffic":  1,
+	"success":  1,
+	"off":      0,
+}
+
+func MapCodeToMode(code string) string {
+	switch code {
+	case "error":
+		return "error"
+	case "permission":
+		return "alarm"
+	case "reasoning":
+		return "thinking"
+	case "busy", "running", "using_tool", "retry", "pending":
+		return "busy"
+	case "idle":
+		return "traffic"
+	case "session_completed", "success":
+		return "success"
+	default:
+		return "ai"
+	}
+}
+
+func modePriorityValue(mode string) int {
+	if p, ok := modePriority[mode]; ok {
+		return p
+	}
+	return 0
+}
+
+func shouldUpdate(currentMode, newMode string) bool {
+	return modePriorityValue(newMode) >= modePriorityValue(currentMode) || currentMode == ""
+}
+
+func logInfo(format string, args ...interface{}) {
+	logger.Info(format, args...)
+}
+
+func logDebug(format string, args ...interface{}) {
+	logger.Debug(format, args...)
+}
+
+func logError(format string, args ...interface{}) {
+	logger.Error(format, args...)
+}
+
+func logWarn(format string, args ...interface{}) {
+	logger.Warn(format, args...)
+}

+ 278 - 0
internal/ble/ble_linux.go

@@ -0,0 +1,278 @@
+//go:build linux
+
+package ble
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/muka/go-bluetooth/api"
+	"github.com/muka/go-bluetooth/bluez"
+	"github.com/muka/go-bluetooth/bluez/profile/adapter"
+	"github.com/muka/go-bluetooth/bluez/profile/device"
+	"github.com/muka/go-bluetooth/bluez/profile/gatt"
+)
+
+type Client struct {
+	deviceName  string
+	modeChan    chan string
+	stopChan    chan struct{}
+	mu          sync.Mutex
+	connected   bool
+	currentMode string
+}
+
+func New(deviceName string) *Client {
+	c := &Client{
+		deviceName: deviceName,
+		modeChan:   make(chan string, 10),
+		stopChan:   make(chan struct{}),
+	}
+	go c.run()
+	return c
+}
+
+func (c *Client) SetMode(mode string) {
+	if mode == "" {
+		return
+	}
+	c.mu.Lock()
+	if mode == c.currentMode {
+		c.mu.Unlock()
+		return
+	}
+	c.mu.Unlock()
+
+	select {
+	case c.modeChan <- mode:
+	default:
+	}
+}
+
+func (c *Client) Close() {
+	close(c.stopChan)
+}
+
+func (c *Client) run() {
+	retryDelay := time.Second
+	maxRetryDelay := 30 * time.Second
+
+	for {
+		select {
+		case <-c.stopChan:
+			return
+		default:
+		}
+
+		char, cleanup, err := c.connect()
+		if err != nil {
+			logWarn("BLE 连接失败: %v, %v后重试", err, retryDelay)
+			select {
+			case <-c.stopChan:
+				return
+			case <-time.After(retryDelay):
+			}
+			retryDelay *= 2
+			if retryDelay > maxRetryDelay {
+				retryDelay = maxRetryDelay
+			}
+			continue
+		}
+
+		retryDelay = time.Second
+		c.mu.Lock()
+		c.connected = true
+		c.mu.Unlock()
+		logInfo("BLE 已连接: %s", c.deviceName)
+
+		disconnected := c.processLoop(char)
+
+		c.mu.Lock()
+		c.connected = false
+		c.mu.Unlock()
+
+		if cleanup != nil {
+			cleanup()
+		}
+
+		if disconnected {
+			logWarn("BLE 断开连接, 1秒后重连")
+			select {
+			case <-c.stopChan:
+				return
+			case <-time.After(time.Second):
+			}
+		}
+	}
+}
+
+func (c *Client) processLoop(char *gatt.GattCharacteristic1) bool {
+	for {
+		select {
+		case <-c.stopChan:
+			return false
+		case mode := <-c.modeChan:
+			if err := c.writeMode(char, mode); err != nil {
+				logError("BLE 写入失败: %v", err)
+				return true
+			}
+			c.mu.Lock()
+			c.currentMode = mode
+			c.mu.Unlock()
+			logDebug("BLE 模式已设置: %s", mode)
+		}
+	}
+}
+
+func (c *Client) connect() (*gatt.GattCharacteristic1, func(), error) {
+	a, err := api.GetDefaultAdapter()
+	if err != nil {
+		return nil, nil, fmt.Errorf("获取蓝牙适配器失败: %w", err)
+	}
+
+	adapterProps, err := adapter.NewAdapter1FromAdapterID(string(a.Path()))
+	if err != nil {
+		return nil, nil, fmt.Errorf("初始化适配器失败: %w", err)
+	}
+
+	powered, err := adapterProps.GetPowered()
+	if err != nil {
+		return nil, nil, fmt.Errorf("获取适配器状态失败: %w", err)
+	}
+	if !powered {
+		if err := adapterProps.SetPowered(true); err != nil {
+			return nil, nil, fmt.Errorf("启动蓝牙适配器失败: %w", err)
+		}
+	}
+
+	dev, cancel, err := c.scanAndConnect(a)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	char, err := c.findCharacteristic(dev)
+	if err != nil {
+		cancel()
+		dev.Disconnect()
+		return nil, nil, err
+	}
+
+	cleanup := func() {
+		dev.Disconnect()
+		cancel()
+	}
+
+	return char, cleanup, nil
+}
+
+func (c *Client) scanAndConnect(a *adapter.Adapter1) (*device.Device1, func(), error) {
+	discover, cancel, err := api.Discover(a, nil)
+	if err != nil {
+		return nil, nil, fmt.Errorf("开始扫描失败: %w", err)
+	}
+
+	timeout := time.After(10 * time.Second)
+
+	for {
+		select {
+		case ev := <-discover:
+			if ev == nil {
+				continue
+			}
+			dev, err := device.NewDevice1(ev.Path)
+			if err != nil {
+				continue
+			}
+			name := dev.Properties.Name
+			if name == c.deviceName {
+				if err := dev.Connect(); err != nil {
+					cancel()
+					return nil, nil, fmt.Errorf("连接设备失败: %w", err)
+				}
+				return dev, cancel, nil
+			}
+		case <-timeout:
+			cancel()
+			return nil, nil, fmt.Errorf("扫描超时,未找到设备 %s", c.deviceName)
+		case <-c.stopChan:
+			cancel()
+			return nil, nil, fmt.Errorf("扫描已取消")
+		}
+	}
+}
+
+func (c *Client) findCharacteristic(dev *device.Device1) (*gatt.GattCharacteristic1, error) {
+	om, err := bluez.GetObjectManager()
+	if err != nil {
+		return nil, fmt.Errorf("获取 ObjectManager 失败: %w", err)
+	}
+
+	objects, err := om.GetManagedObjects()
+	if err != nil {
+		return nil, fmt.Errorf("获取管理对象失败: %w", err)
+	}
+
+	devPath := string(dev.Path())
+
+	var servicePath string
+	for path, ifaces := range objects {
+		pathStr := string(path)
+		if !strings.HasPrefix(pathStr, devPath+"/") {
+			continue
+		}
+		if _, ok := ifaces[gatt.GattService1Interface]; ok {
+			svc, err := gatt.NewGattService1(path)
+			if err != nil {
+				continue
+			}
+			uuid, err := svc.GetUUID()
+			if err != nil {
+				continue
+			}
+			if uuid == serviceUUID {
+				servicePath = pathStr
+				break
+			}
+		}
+	}
+
+	if servicePath == "" {
+		return nil, fmt.Errorf("服务 %s 未找到", serviceUUID)
+	}
+
+	for path, ifaces := range objects {
+		pathStr := string(path)
+		if !strings.HasPrefix(pathStr, servicePath+"/") {
+			continue
+		}
+		if _, ok := ifaces[gatt.GattCharacteristic1Interface]; ok {
+			char, err := gatt.NewGattCharacteristic1(path)
+			if err != nil {
+				continue
+			}
+			uuid, err := char.GetUUID()
+			if err != nil {
+				continue
+			}
+			if uuid == charUUID {
+				return char, nil
+			}
+		}
+	}
+
+	return nil, fmt.Errorf("特征 %s 未找到", charUUID)
+}
+
+func (c *Client) writeMode(char *gatt.GattCharacteristic1, mode string) error {
+	options := map[string]interface{}{
+		"type": "request",
+	}
+	return char.WriteValue([]byte(mode), options)
+}
+
+const (
+	serviceUUID = "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001"
+	charUUID    = "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001"
+)

+ 32 - 0
internal/ble/ble_stub.go

@@ -0,0 +1,32 @@
+//go:build !linux && !windows
+
+package ble
+
+import "fmt"
+
+type Client struct {
+	deviceName  string
+	modeChan    chan string
+	stopChan    chan struct{}
+	connected   bool
+	currentMode string
+}
+
+func New(deviceName string) *Client {
+	logWarn("BLE 不支持当前平台,蓝牙功能已禁用")
+	return &Client{
+		deviceName: deviceName,
+		modeChan:   make(chan string, 10),
+		stopChan:   make(chan struct{}),
+	}
+}
+
+func (c *Client) SetMode(mode string) {
+	logDebug("BLE SetMode 被调用但平台不支持: %s", mode)
+}
+
+func (c *Client) Close() {
+	close(c.stopChan)
+}
+
+var ErrUnsupported = fmt.Errorf("BLE 不支持当前平台")

+ 32 - 0
internal/ble/ble_windows.go

@@ -0,0 +1,32 @@
+//go:build windows
+
+package ble
+
+import "fmt"
+
+type Client struct {
+	deviceName  string
+	modeChan    chan string
+	stopChan    chan struct{}
+	connected   bool
+	currentMode string
+}
+
+func New(deviceName string) *Client {
+	logWarn("Windows BLE 暂不支持,蓝牙功能已禁用 (winrt-go 缺少 enumeration 包)")
+	return &Client{
+		deviceName: deviceName,
+		modeChan:   make(chan string, 10),
+		stopChan:   make(chan struct{}),
+	}
+}
+
+func (c *Client) SetMode(mode string) {
+	logDebug("BLE SetMode 被调用但 Windows 暂不支持: %s", mode)
+}
+
+func (c *Client) Close() {
+	close(c.stopChan)
+}
+
+var ErrUnsupported = fmt.Errorf("Windows BLE 暂不支持")

+ 8 - 13
internal/database/database.go

@@ -88,9 +88,9 @@ func (d *DB) init() error {
 	bleQuery := `
 	CREATE TABLE IF NOT EXISTS ble_config (
 		id INTEGER PRIMARY KEY AUTOINCREMENT,
-		device_name TEXT NOT NULL DEFAULT 'AI-Light',
-		service_uuid TEXT NOT NULL DEFAULT 'b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001',
-		char_uuid TEXT NOT NULL DEFAULT 'b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001',
+		device_name TEXT NOT NULL,
+		service_uuid TEXT NOT NULL,
+		char_uuid TEXT NOT NULL,
 		enabled BOOLEAN DEFAULT 1
 	);
 	`
@@ -99,6 +99,11 @@ func (d *DB) init() error {
 		return err
 	}
 
+	for _, col := range []string{"service_uuid", "char_uuid"} {
+		alterQuery := fmt.Sprintf("ALTER TABLE ble_config ADD COLUMN %s TEXT NOT NULL DEFAULT ''", col)
+		d.conn.ExecContext(context.Background(), alterQuery)
+	}
+
 	return nil
 }
 
@@ -197,16 +202,6 @@ func (d *DB) GetBLEConfig() (*BLEConfig, error) {
 }
 
 func (d *DB) SaveBLEConfig(cfg *BLEConfig) error {
-	if cfg.DeviceName == "" {
-		cfg.DeviceName = "AI-Light"
-	}
-	if cfg.ServiceUUID == "" {
-		cfg.ServiceUUID = "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001"
-	}
-	if cfg.CharUUID == "" {
-		cfg.CharUUID = "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001"
-	}
-
 	if cfg.ID == 0 {
 		query := "INSERT INTO ble_config (device_name, service_uuid, char_uuid, enabled) VALUES (?, ?, ?, ?)"
 		_, err := d.conn.ExecContext(context.Background(), query, cfg.DeviceName, cfg.ServiceUUID, cfg.CharUUID, cfg.Enabled)

+ 21 - 58
internal/discovery/discovery_darwin.go

@@ -8,20 +8,23 @@ import (
 	"sort"
 	"strconv"
 	"strings"
+
+	"ai-status-light/internal/logger"
 )
 
 func findByPID() []int {
 	var ports []int
-	pidRegex := regexp.MustCompile(`\bopencode\b`)
+	pidRegex := regexp.MustCompile(`(?:^|/)opencode(?:\s|$)`)
 
 	out, err := exec.Command("ps", "aux").Output()
 	if err != nil {
+		logger.Debug("执行 ps aux 失败: %v", err)
 		return ports
 	}
 
 	var pids []int
 	for _, line := range strings.Split(string(out), "\n") {
-		if pidRegex.MatchString(line) && !strings.Contains(line, "opencode-monitor") && !strings.Contains(line, "grep") {
+		if pidRegex.MatchString(line) && !strings.Contains(line, "grep") && !strings.Contains(line, "opencode-monitor") {
 			fields := strings.Fields(line)
 			if len(fields) > 1 {
 				if pid, err := strconv.Atoi(fields[1]); err == nil {
@@ -31,21 +34,31 @@ func findByPID() []int {
 		}
 	}
 
+	if len(pids) == 0 {
+		logger.Debug("未找到 opencode 进程")
+		return ports
+	}
+	logger.Debug("发现 opencode 进程 PID: %v", pids)
+
 	for _, pid := range pids {
-		lsofOut, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-i", "TCP", "-n").Output()
+		lsofOut, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-i", "TCP", "-s", "TCP:LISTEN").Output()
 		if err != nil {
+			logger.Debug("执行 lsof 失败: %v", err)
 			continue
 		}
 
 		for _, line := range strings.Split(string(lsofOut), "\n") {
 			if strings.Contains(line, "LISTEN") {
+				// lsof 输出格式: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
+				// NAME 部分格式: *:port 或 host:port
 				fields := strings.Fields(line)
-				for _, f := range fields {
-					if strings.Contains(f, ":") {
-						parts := strings.Split(f, ":")
+				if len(fields) >= 9 {
+					nameField := fields[len(fields)-1]
+					if strings.Contains(nameField, ":") {
+						parts := strings.Split(nameField, ":")
 						if len(parts) >= 2 {
 							if port, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
-								if !contains(ports, port) {
+								if port > 0 && port < 65535 && !contains(ports, port) {
 									ports = append(ports, port)
 								}
 							}
@@ -54,28 +67,6 @@ func findByPID() []int {
 				}
 			}
 		}
-
-		netstatOut, err := exec.Command("netstat", "-an").Output()
-		if err == nil {
-			for _, line := range strings.Split(string(netstatOut), "\n") {
-				if strings.Contains(line, "LISTEN") {
-					fields := strings.Fields(line)
-					if len(fields) >= 4 {
-						addr := fields[3]
-						if strings.Contains(addr, ":") {
-							parts := strings.Split(addr, ":")
-							if len(parts) >= 2 {
-								if port, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
-									if port > 1024 && port < 65535 && !contains(ports, port) {
-										ports = append(ports, port)
-									}
-								}
-							}
-						}
-					}
-				}
-			}
-		}
 	}
 
 	sort.Ints(ports)
@@ -87,6 +78,7 @@ func findByCmdline() []int {
 
 	out, err := exec.Command("ps", "aux").Output()
 	if err != nil {
+		logger.Debug("执行 ps aux 失败: %v", err)
 		return ports
 	}
 
@@ -110,32 +102,3 @@ func findByCmdline() []int {
 	sort.Ints(ports)
 	return ports
 }
-
-func getListeningPorts(pid int) []int {
-	var ports []int
-
-	lsofOut, err := exec.Command("lsof", "-p", strconv.Itoa(pid), "-i", "TCP", "-n", "-P").Output()
-	if err != nil {
-		return ports
-	}
-
-	for _, line := range strings.Split(string(lsofOut), "\n") {
-		if strings.Contains(line, "LISTEN") {
-			fields := strings.Fields(line)
-			for _, f := range fields {
-				if strings.Contains(f, ":") {
-					parts := strings.Split(f, ":")
-					if len(parts) >= 2 {
-						if port, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
-							if !contains(ports, port) {
-								ports = append(ports, port)
-							}
-						}
-					}
-				}
-			}
-		}
-	}
-
-	return ports
-}

+ 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>