Эх сурвалжийг харах

Merge remote-tracking branch 'origin/master'

# Conflicts:
#	Makefile
#	cmd/monitor/main.go
#	go.mod
#	internal/database/database.go
#	scripts/build.sh
moki 1 долоо хоног өмнө
parent
commit
f3ab728cee

+ 6 - 0
.gitignore

@@ -1,6 +1,7 @@
 # Binaries
 bin/
 dist/
+build/
 *.exe
 *.exe~
 *.dll
@@ -38,3 +39,8 @@ logs/
 
 # Frontend build artifact
 internal/web/html/
+
+# BLE relay build artifact (embedded into Go binary)
+cmd/monitor/ble_relay
+cmd/monitor/ble_relay.exe
+data/.venv/

+ 22 - 2
Makefile

@@ -1,4 +1,4 @@
-.PHONY: build clean tidy run serve build-all build-linux build-windows
+.PHONY: build clean tidy run serve build-all build-linux build-darwin build-windows build-with-ble
 
 BINARY_NAME=opencode-monitor
 VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -29,7 +29,7 @@ config-list: build
 config-set: build
 	./bin/$(BINARY_NAME) config set --broker tcp://127.0.0.1:1883
 
-build-all: build-linux build-windows
+build-all: build-linux build-darwin build-windows
 
 build-linux:
 	@echo "Building for Linux..."
@@ -37,6 +37,12 @@ build-linux:
 	GOOS=linux GOARCH=amd64 $(GO) build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-linux-amd64 ./cmd/monitor
 	GOOS=linux GOARCH=arm64 $(GO) build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-linux-arm64 ./cmd/monitor
 
+build-darwin:
+	@echo "Building for macOS..."
+	@mkdir -p dist
+	GOOS=darwin GOARCH=amd64 $(GO) build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-darwin-amd64 ./cmd/monitor
+	GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "-X main.Version=$(VERSION)" -o dist/$(BINARY_NAME)-darwin-arm64 ./cmd/monitor
+
 build-windows:
 	@echo "Building for Windows..."
 	@mkdir -p dist
@@ -47,6 +53,20 @@ release: build-all
 	@echo "Creating release archives..."
 	@cd dist && tar -czf $(BINARY_NAME)-linux-amd64.tar.gz $(BINARY_NAME)-linux-amd64
 	@cd dist && tar -czf $(BINARY_NAME)-linux-arm64.tar.gz $(BINARY_NAME)-linux-arm64
+	@cd dist && tar -czf $(BINARY_NAME)-darwin-amd64.tar.gz $(BINARY_NAME)-darwin-amd64
+	@cd dist && tar -czf $(BINARY_NAME)-darwin-arm64.tar.gz $(BINARY_NAME)-darwin-arm64
 	@cd dist && zip -q $(BINARY_NAME)-windows-amd64.zip $(BINARY_NAME)-windows-amd64.exe
 	@cd dist && zip -q $(BINARY_NAME)-windows-arm64.zip $(BINARY_NAME)-windows-arm64.exe
 	@echo "Release archives created in dist/"
+
+build-with-ble:
+	@echo "Step 1: Building BLE relay with PyInstaller..."
+	python -m PyInstaller --onefile --name ble_relay --distpath bin --specpath build --workpath build scripts/ble_relay.py
+	@echo "Step 2: Copying BLE relay for embedding..."
+	cp bin/ble_relay cmd/monitor/ble_relay
+	@echo "Step 3: Building Go binary with BLE embedded..."
+	$(GO) mod tidy
+	$(GO) build -tags ble -ldflags "-X main.Version=$(VERSION)" -o bin/$(BINARY_NAME) ./cmd/monitor
+	@echo "Step 4: Cleaning up..."
+	rm -f cmd/monitor/ble_relay
+	@echo "Done: bin/$(BINARY_NAME)"

+ 74 - 10
README.md

@@ -1,13 +1,14 @@
 # OpenCode Monitor
 
-OpenCode 状态监控工具,支持实时监控多个 OpenCode 实例的状态,通过 MQTT 推送状态信息。
+OpenCode 状态监控工具,支持实时监控多个 OpenCode 实例的状态,通过 MQTT 或 BLE 蓝牙推送状态信息。
 
 ## 功能特性
 
 - 🔍 **自动发现** - 自动扫描并发现运行中的 OpenCode 实例
 - 📊 **实时监控** - 通过 SSE 事件流实时获取状态变化
 - 📡 **MQTT 推送** - 支持将状态信息推送到 MQTT Broker
-- 💾 **配置管理** - 使用 SQLite 存储 MQTT 配置
+- 🟢 **BLE 蓝牙推送** - 通过蓝牙将状态推送到 [AI-Light](firmware/) 等 BLE 设备
+- 💾 **配置管理** - 使用 SQLite 存储 MQTT 和 BLE 配置
 - 🌐 **HTTP API** - 提供 RESTful API 接口管理配置(详见 [API 文档](docs/api.md))
 - 🔌 **WebSocket** - 支持通过 WebSocket 实时推送状态到网页(详见 [API 文档](docs/api.md))
 - 🖥️ **跨平台** - 支持 Linux、Windows
@@ -30,11 +31,18 @@ OpenCode 状态监控工具,支持实时监控多个 OpenCode 实例的状态
 git clone <repository-url>
 cd AI-Status-Light
 
-# 编译当前平台
+# 当前平台(不含 BLE)
 go build -o bin/opencode-monitor ./cmd/monitor
 
-# 编译所有平台
-make build-all
+# 当前平台(含 BLE 嵌入)
+scripts\build.bat --ble          # Windows
+bash scripts/build.sh --ble      # Linux/macOS
+
+# 仅打包 BLE 中继(供 IDE 调试用)
+scripts\build.bat --only-ble
+
+# 所有平台(不含 BLE)
+scripts\build.bat
 ```
 
 ## 使用方法
@@ -79,6 +87,31 @@ API 接口详情请参阅 [API 文档](docs/api.md)。
 ./opencode-monitor config delete 1
 ```
 
+### BLE 蓝牙配置
+
+BLE 中继已嵌入 Go 二进制(使用 `make build-with-ble` 构建时),无需额外文件或 Python 环境。
+
+```bash
+# 查看 BLE 配置
+./opencode-monitor config ble list
+
+# 设置 BLE 配置(使用默认设备 AI-Light)
+./opencode-monitor config ble set
+
+# 设置 BLE 配置(自定义设备)
+./opencode-monitor config ble set --device MyLight --service-uuid "xxx" --char-uuid "yyy"
+
+# 删除 BLE 配置
+./opencode-monitor config ble delete 1
+```
+
+启动监控时,如果存在启用的 BLE 配置,会自动启动蓝牙中继:
+
+```bash
+# 启动监控(自动根据配置启用 MQTT 和/或 BLE)
+./opencode-monitor monitor --api-addr :8080
+```
+
 ## MQTT 消息格式
 
 消息格式为 JSON:
@@ -150,11 +183,26 @@ API 接口详情请参阅 [API 文档](docs/api.md)。
   --db              数据库路径 (默认 "./data/config.db")
 ```
 
+### config ble set 命令
+
+```bash
+./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")
+  --enabled         是否启用 (true/false, 默认 true)
+```
+
 ## 项目结构
 
 ```
 AI-Status-Light/
-├── cmd/monitor/          # 程序入口
+├── cmd/monitor/
+│   ├── main.go                    # 程序入口
+│   ├── ble_embed.go               # BLE 嵌入声明 (go:build ble)
+│   └── ble_embed_none.go          # BLE 空声明 (go:build !ble)
 ├── internal/
 │   ├── api/              # HTTP API 模块
 │   ├── database/         # SQLite 数据库模块
@@ -162,8 +210,16 @@ AI-Status-Light/
 │   ├── event/            # 事件处理模块
 │   ├── monitor/          # 监控器核心模块
 │   └── mqtt/             # MQTT 客户端模块
+├── firmware/             # ESP32-C3 硬件固件
+│   ├── ble/              # BLE 蓝牙固件
+│   ├── mqtt/             # MQTT 固件
+│   └── README.md         # 硬件说明
+├── scripts/
+│   ├── build.bat                 # Windows 构建脚本
+│   ├── build.sh                  # Linux/macOS 构建脚本
+│   ├── ble_relay.py              # Python BLE 蓝牙中继脚本
+│   └── requirements.txt          # Python 依赖
 ├── docs/                 # 文档
-├── scripts/              # 构建脚本
 └── Makefile              # 构建配置
 ```
 
@@ -176,10 +232,18 @@ go mod tidy
 # 运行测试
 go test ./...
 
-# 构建
-make build
+# 构建(不含 BLE,全平台)
+scripts\build.bat
 
-# 构建所有平台
+# 构建(含 BLE,当前平台)
+scripts\build.bat --ble
+
+# 仅生成 BLE 中继(IDE 调试用)
+scripts\build.bat --only-ble
+
+# 或用 Makefile
+make build
+make build-with-ble
 make build-all
 ```
 

+ 8 - 0
cmd/monitor/ble_embed.go

@@ -0,0 +1,8 @@
+//go:build ble
+
+package main
+
+import _ "embed"
+
+//go:embed ble_relay.exe
+var embeddedBLERelay []byte

+ 5 - 0
cmd/monitor/ble_embed_none.go

@@ -0,0 +1,5 @@
+//go:build !ble
+
+package main
+
+var embeddedBLERelay []byte

+ 158 - 54
cmd/monitor/main.go

@@ -4,8 +4,11 @@ import (
 	"context"
 	"flag"
 	"fmt"
+	"io"
 	"os"
+	"os/exec"
 	"os/signal"
+	"path/filepath"
 	"sort"
 	"strconv"
 	"strings"
@@ -14,7 +17,6 @@ import (
 	"time"
 
 	"ai-status-light/internal/api"
-	"ai-status-light/internal/ble"
 	"ai-status-light/internal/database"
 	"ai-status-light/internal/discovery"
 	"ai-status-light/internal/event"
@@ -53,7 +55,7 @@ func printUsage() {
 	fmt.Println("")
 	fmt.Println("命令:")
 	fmt.Println("  monitor    启动监控")
-	fmt.Println("  config     管理 MQTT 配置")
+	fmt.Println("  config     管理 MQTT 和 BLE 配置")
 	fmt.Println("  serve      启动 API 服务")
 	fmt.Println("  version    显示版本信息")
 	fmt.Println("")
@@ -104,11 +106,16 @@ func runServe(args []string) {
 
 	fmt.Println("接口文档:")
 	fmt.Println("  GET    /api/health       - 健康检查")
-	fmt.Println("  GET    /api/mqtt         - 获取所有配置")
-	fmt.Println("  POST   /api/mqtt         - 创建配置")
-	fmt.Println("  GET    /api/mqtt/:id     - 获取单个配置")
-	fmt.Println("  PUT    /api/mqtt/:id     - 更新配置")
-	fmt.Println("  DELETE /api/mqtt/:id     - 删除配置")
+	fmt.Println("  GET    /api/mqtt         - 获取所有 MQTT 配置")
+	fmt.Println("  POST   /api/mqtt         - 创建 MQTT 配置")
+	fmt.Println("  GET    /api/mqtt/:id     - 获取单个 MQTT 配置")
+	fmt.Println("  PUT    /api/mqtt/:id     - 更新 MQTT 配置")
+	fmt.Println("  DELETE /api/mqtt/:id     - 删除 MQTT 配置")
+	fmt.Println("  GET    /api/ble          - 获取所有 BLE 配置")
+	fmt.Println("  POST   /api/ble          - 创建 BLE 配置")
+	fmt.Println("  GET    /api/ble/:id      - 获取单个 BLE 配置")
+	fmt.Println("  PUT    /api/ble/:id      - 更新 BLE 配置")
+	fmt.Println("  DELETE /api/ble/:id      - 删除 BLE 配置")
 
 	if err := server.Start(); err != nil {
 		logger.Error("服务启动失败: %v", err)
@@ -185,17 +192,15 @@ func runMonitor(args []string) {
 		logger.Info("未配置 MQTT,跳过 MQTT 连接")
 	}
 
-	var bleClient *ble.Client
+	var bleStdin io.WriteCloser
 	bleCfg, err := db.GetBLEConfig()
 	if err != nil {
 		logger.Error("读取 BLE 配置失败: %v", err)
+		fmt.Printf("读取 BLE 配置失败: %v\n", err)
 	} else if bleCfg != nil {
-		bleClient = ble.New(bleCfg.DeviceName)
-		defer bleClient.Close()
-		logger.Info("BLE 已启用: 设备 %s", bleCfg.DeviceName)
-		fmt.Printf("BLE 已启用: 设备 %s\n", bleCfg.DeviceName)
+		bleStdin = startBLERelay(bleCfg, ctx)
 	} else {
-		logger.Info("未配置 BLE,跳过蓝牙连接")
+		logger.Info("未配置 BLE,跳过 BLE 中继")
 	}
 
 	var apiServer *api.Server
@@ -223,7 +228,7 @@ func runMonitor(args []string) {
 		}()
 	}
 
-	callback := createCallback(mqttClient, apiServer, bleClient)
+	callback := createCallback(mqttClient, apiServer, bleStdin)
 
 	if *portsFlag != "" {
 		runFixedMode(ctx, *host, *portsFlag, callback, sigChan)
@@ -366,7 +371,113 @@ func runConfig(args []string) {
 		logger.Info("MQTT 配置已删除: id=%d", id)
 		fmt.Println("配置已删除")
 
-	case "ble-list":
+	case "ble":
+		runBleConfig(db, args[1:])
+
+	default:
+		printConfigUsage()
+	}
+}
+
+func printConfigUsage() {
+	fmt.Println("用法: opencode-monitor config <子命令> [选项]")
+	fmt.Println("")
+	fmt.Println("子命令:")
+	fmt.Println("  list              列出所有 MQTT 配置")
+	fmt.Println("  set               设置 MQTT 配置")
+	fmt.Println("  delete <id>       删除 MQTT 配置")
+	fmt.Println("  ble list          列出所有 BLE 配置")
+	fmt.Println("  ble set           设置 BLE 配置")
+	fmt.Println("  ble delete <id>   删除 BLE 配置")
+	fmt.Println("")
+	fmt.Println("MQTT 选项:")
+	fmt.Println("  --broker          MQTT Broker 地址")
+	fmt.Println("  --client-id       MQTT 客户端 ID")
+	fmt.Println("  --username        MQTT 用户名")
+	fmt.Println("  --password        MQTT 密码")
+	fmt.Println("  --topic           MQTT 主题")
+	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("  --enabled         是否启用 (true/false)")
+	fmt.Println("")
+	fmt.Println("全局选项:")
+	fmt.Println("  --db              数据库路径")
+	fmt.Println("  --log-file        日志文件路径(默认 ./logs/monitor.log)")
+	fmt.Println("  --log-level       日志级别 (debug/info/warn/error)")
+}
+
+func startBLERelay(bleCfg *database.BLEConfig, ctx context.Context) io.WriteCloser {
+	if len(embeddedBLERelay) == 0 {
+		logger.Warn("BLE 中继未嵌入,请使用 make build-with-ble 构建")
+		fmt.Println("警告: BLE 中继未嵌入,已跳过。请使用 make build-with-ble 构建")
+		return nil
+	}
+
+	// 释放嵌入的 exe 到临时目录
+	tmpDir := filepath.Join(os.TempDir(), "ai-status-light")
+	if err := os.MkdirAll(tmpDir, 0755); err != nil {
+		logger.Error("创建临时目录失败: %v", err)
+		return nil
+	}
+
+	var tmpExe string
+	if strings.Contains(strings.ToLower(os.Getenv("OS")), "windows") {
+		tmpExe = filepath.Join(tmpDir, "ble_relay.exe")
+	} else {
+		tmpExe = filepath.Join(tmpDir, "ble_relay")
+	}
+
+	if err := os.WriteFile(tmpExe, embeddedBLERelay, 0755); err != nil {
+		logger.Error("释放 BLE 中继失败: %v", err)
+		return nil
+	}
+	logger.Debug("BLE 中继已释放到: %s", tmpExe)
+
+	args := []string{
+		"--device", bleCfg.DeviceName,
+		"--service-uuid", bleCfg.ServiceUUID,
+		"--char-uuid", bleCfg.CharUUID,
+	}
+
+	cmd := exec.CommandContext(ctx, tmpExe, args...)
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		logger.Error("创建 BLE 中继 stdin 管道失败: %v", err)
+		return nil
+	}
+
+	if err := cmd.Start(); err != nil {
+		logger.Error("启动 BLE 中继失败: %v", err)
+		return nil
+	}
+
+	logger.Info("BLE 中继已启动: %s (PID: %d)", bleCfg.DeviceName, cmd.Process.Pid)
+	fmt.Printf("BLE 中继已启动: %s (PID: %d)\n", bleCfg.DeviceName, cmd.Process.Pid)
+
+	go func() {
+		if err := cmd.Wait(); err != nil {
+			logger.Error("BLE 中继退出: %v", err)
+		}
+	}()
+
+	return stdin
+}
+
+func runBleConfig(db *database.DB, args []string) {
+	if len(args) < 1 {
+		printBleConfigUsage()
+		return
+	}
+
+	switch args[0] {
+	case "list":
 		configs, err := db.ListBLEConfigs()
 		if err != nil {
 			logger.Error("查询 BLE 配置失败: %v", err)
@@ -377,23 +488,28 @@ func runConfig(args []string) {
 			fmt.Println("未配置 BLE")
 			return
 		}
+		logger.Info("查询到 %d 条 BLE 配置", len(configs))
 		for _, cfg := range configs {
 			status := "禁用"
 			if cfg.Enabled {
 				status = "启用"
 			}
-			fmt.Printf("[%d] %s | %s\n", cfg.ID, cfg.DeviceName, status)
+			fmt.Printf("[%d] %s | %s | %s | %s\n", cfg.ID, cfg.DeviceName, cfg.ServiceUUID, cfg.CharUUID, status)
 		}
 
-	case "ble-set":
-		fs := flag.NewFlagSet("config ble-set", flag.ExitOnError)
-		deviceName := fs.String("device", ble.DefaultDeviceName, "蓝牙设备名称")
+	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")
 		enabled := fs.Bool("enabled", true, "是否启用")
 		fs.Parse(args[1:])
 
 		cfg := &database.BLEConfig{
-			DeviceName: *deviceName,
-			Enabled:    *enabled,
+			DeviceName:  *deviceName,
+			ServiceUUID: *serviceUUID,
+			CharUUID:    *charUUID,
+			Enabled:     *enabled,
 		}
 
 		if err := db.SaveBLEConfig(cfg); err != nil {
@@ -402,16 +518,16 @@ func runConfig(args []string) {
 			return
 		}
 		logger.Info("BLE 配置已保存: %s", cfg.DeviceName)
-		fmt.Println("BLE 配置已保存")
+		fmt.Println("配置已保存")
 
-	case "ble-delete":
+	case "delete":
 		if len(args) < 2 {
 			fmt.Println("必须指定配置 ID")
 			return
 		}
 		id, err := strconv.Atoi(args[1])
 		if err != nil {
-			logger.Warn("无效的 BLE 配置 ID: %s", args[1])
+			logger.Warn("无效的配置 ID: %s", args[1])
 			fmt.Println("无效的 ID")
 			return
 		}
@@ -421,43 +537,29 @@ func runConfig(args []string) {
 			return
 		}
 		logger.Info("BLE 配置已删除: id=%d", id)
-		fmt.Println("BLE 配置已删除")
+		fmt.Println("配置已删除")
 
 	default:
-		printConfigUsage()
+		printBleConfigUsage()
 	}
 }
 
-func printConfigUsage() {
-	fmt.Println("用法: opencode-monitor config <子命令> [选项]")
+func printBleConfigUsage() {
+	fmt.Println("用法: opencode-monitor config ble <子命令> [选项]")
 	fmt.Println("")
 	fmt.Println("子命令:")
-	fmt.Println("  list              列出所有 MQTT 配置")
-	fmt.Println("  set               设置 MQTT 配置")
-	fmt.Println("  delete <id>       删除 MQTT 配置")
-	fmt.Println("  ble-list          列出所有 BLE 配置")
-	fmt.Println("  ble-set           设置 BLE 配置")
-	fmt.Println("  ble-delete <id>   删除 BLE 配置")
+	fmt.Println("  list              列出所有 BLE 配置")
+	fmt.Println("  set               设置 BLE 配置")
+	fmt.Println("  delete <id>       删除 BLE 配置")
 	fmt.Println("")
-	fmt.Println("MQTT 选项:")
-	fmt.Println("  --broker          MQTT Broker 地址")
-	fmt.Println("  --client-id       MQTT 客户端 ID")
-	fmt.Println("  --username        MQTT 用户名")
-	fmt.Println("  --password        MQTT 密码")
-	fmt.Println("  --topic           MQTT 主题")
+	fmt.Println("选项:")
+	fmt.Println("  --device          蓝牙设备名称 (默认: AI-Light)")
+	fmt.Println("  --service-uuid    BLE 服务 UUID")
+	fmt.Println("  --char-uuid       BLE 特征 UUID")
 	fmt.Println("  --enabled         是否启用 (true/false)")
-	fmt.Println("")
-	fmt.Println("BLE 选项:")
-	fmt.Println("  --device          蓝牙设备名称 (默认 AI-Light)")
-	fmt.Println("  --enabled         是否启用 (true/false)")
-	fmt.Println("")
-	fmt.Println("全局选项:")
-	fmt.Println("  --db              数据库路径")
-	fmt.Println("  --log-file        日志文件路径(默认 ./logs/monitor.log)")
-	fmt.Println("  --log-level       日志级别 (debug/info/warn/error)")
 }
 
-func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server, bleClient *ble.Client) monitor.EventCallback {
+func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server, bleStdin io.Writer) monitor.EventCallback {
 	lastStatus := make(map[int]string)
 	var mu sync.Mutex
 
@@ -477,9 +579,11 @@ func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server, bleClient
 		if apiServer != nil {
 			apiServer.BroadcastStatus(port, status, code)
 		}
-		if bleClient != nil {
-			mode := ble.MapCodeToMode(code)
-			bleClient.SetMode(mode)
+		if bleStdin != nil {
+			msg := fmt.Sprintf(`{"port":%d,"code":"%s"}`+"\n", port, code)
+			if _, err := bleStdin.Write([]byte(msg)); err != nil {
+				logger.Error("BLE 发送失败: %v", err)
+			}
 		}
 	}
 
@@ -489,7 +593,7 @@ func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server, bleClient
 			fmt.Println(msg)
 		}
 
-		if mqttClient == nil && apiServer == nil {
+		if mqttClient == nil && apiServer == nil && bleStdin == nil {
 			return
 		}
 

+ 149 - 0
docs/api.md

@@ -178,6 +178,110 @@ DELETE /api/mqtt/:id
 }
 ```
 
+### 7. 获取所有 BLE 配置
+
+```
+GET /api/ble
+```
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "ok",
+  "data": [
+    {
+      "id": 1,
+      "device_name": "AI-Light",
+      "service_uuid": "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001",
+      "char_uuid": "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001",
+      "enabled": true
+    }
+  ]
+}
+```
+
+### 8. 创建 BLE 配置
+
+```
+POST /api/ble
+```
+
+**请求体:**
+```json
+{
+  "device_name": "AI-Light",
+  "service_uuid": "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001",
+  "char_uuid": "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001",
+  "enabled": true
+}
+```
+
+| 字段 | 类型 | 必填 | 默认值 | 说明 |
+|------|------|------|--------|------|
+| device_name | string | 否 | AI-Light | BLE 设备名称 |
+| service_uuid | string | 否 | b8b7e001-... | BLE 服务 UUID |
+| char_uuid | string | 否 | b8b7e002-... | BLE 特征 UUID |
+| enabled | boolean | 否 | true | 是否启用 |
+
+**响应示例:**
+```json
+{
+  "code": 0,
+  "message": "创建成功",
+  "data": {
+    "id": 1,
+    "device_name": "AI-Light",
+    "service_uuid": "b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001",
+    "char_uuid": "b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001",
+    "enabled": true
+  }
+}
+```
+
+### 9. 获取单个 BLE 配置
+
+```
+GET /api/ble/:id
+```
+
+**路径参数:**
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| id | integer | 配置 ID |
+
+### 10. 更新 BLE 配置
+
+```
+PUT /api/ble/:id
+```
+
+**路径参数:**
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| id | integer | 配置 ID |
+
+**请求体:**
+```json
+{
+  "device_name": "MyLight",
+  "service_uuid": "xxx",
+  "char_uuid": "yyy",
+  "enabled": false
+}
+```
+
+### 11. 删除 BLE 配置
+
+```
+DELETE /api/ble/:id
+```
+
+**路径参数:**
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| id | integer | 配置 ID |
+
 ## 错误响应
 
 所有错误响应格式:
@@ -265,6 +369,51 @@ API 已启用 CORS,支持跨域请求。
 | 等待权限 | permission | AI 向用户申请权限 |
 | 错误 | error | 会话错误 |
 
+## BLE 蓝牙推送说明
+
+配置 BLE 后,监控到状态变化时会自动通过蓝牙发送到设备。BLE 中继已嵌入 Go 二进制,无需额外文件或 Python 环境。
+
+### 构建含 BLE 的版本
+
+```bash
+# 一键构建(打包 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
+```
+
+### 状态映射
+
+| Go 状态码 | 蓝牙模式 | 说明 |
+|-----------|---------|------|
+| idle | idle | 空闲 |
+| busy | busy | 工作中 |
+| retry | thinking | 重试中 |
+| pending | thinking | 等待/修改中 |
+| reasoning | thinking | 思考中 |
+| using_tool | ai | 使用工具 |
+| running | ai | 工具运行中 |
+| completed | success | 完成 |
+| session_completed | success | 会话完成 |
+| permission | alarm | 等待权限 |
+| error | error | 错误 |
+
+### Python 依赖(仅 Python 方式需要)
+
+```bash
+pip install -r scripts/requirements.txt
+```
+
+如果使用预编译二进制则不需要安装。
+
 ## WebSocket 实时状态
 
 启动监控服务后,可以通过浏览器访问 `http://localhost:8080` 查看实时状态页面。

+ 62 - 0
firmware/README.md

@@ -0,0 +1,62 @@
+# AI-Light 固件
+
+基于 **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 | 改进版 |
+
+## BLE 参数
+
+```
+Device Name:          AI-Light
+Service UUID:         b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001
+Characteristic UUID:  b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001
+```
+
+写入 UTF-8 字符串即可控制灯效,支持的模式:
+
+| 模式 | 灯效 | 典型场景 |
+|------|------|----------|
+| `init` | 开机初始化 | 启动 |
+| `thinking` | 跑马灯 | AI 分析中 |
+| `ai` | 慢速跑马灯 | AI 生成中 |
+| `busy` | 黄灯慢闪 | 命令执行中 |
+| `success` | 绿灯常亮 | 任务成功 |
+| `error` | 红灯快闪 | 任务失败 |
+| `alarm` | 红黄交替警灯 | 严重异常 |
+| `traffic` | 模拟红绿灯 | 展示模式 |
+| `off` | 全灭 | 关闭 |
+
+## 硬件接线
+
+基于公共正极灯板:
+
+```
+ESP32 3.3V  -> 灯板正极
+ESP32 IO2   -> 220Ω -> 绿灯
+ESP32 IO3   -> 220Ω -> 红灯
+ESP32 IO4   -> 220Ω -> 黄灯
+```
+
+公共正极逻辑:GPIO LOW = 灯亮,GPIO HIGH = 灯灭
+
+## 烧录方法
+
+1. 安装 [Arduino IDE 2.x](https://www.arduino.cc/en/software)
+2. 安装 ESP32 开发板包:`esp32 by Espressif Systems`
+3. 选择开发板:`ESP32C3 Dev Module`
+4. 打开 `.ino` 文件,点击 Upload
+5. 串口监视器波特率:`115200`
+
+## 依赖库(MQTT 版)
+
+- `WiFi.h`
+- `PubSubClient.h`
+- `ArduinoJson.h`
+
+通过 Arduino IDE → Sketch → Include Library → Manage Libraries 安装。

+ 498 - 0
firmware/ble/ai_light.ino

@@ -0,0 +1,498 @@
+#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);
+}

+ 677 - 0
firmware/mqtt/ai_light.ino

@@ -0,0 +1,677 @@
+#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);
+}

+ 677 - 0
firmware/mqtt/ai_light_v2.ino

@@ -0,0 +1,677 @@
+#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);
+}

+ 3 - 8
go.mod

@@ -5,26 +5,21 @@ 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
-	github.com/stretchr/testify v1.7.5 // indirect
-	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // 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
 	golang.org/x/sys v0.6.0 // indirect
-	golang.org/x/tools v0.1.11 // indirect
+	golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
+	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	lukechampine.com/uint128 v1.2.0 // indirect
 	modernc.org/cc/v3 v3.40.0 // indirect
 	modernc.org/ccgo/v3 v3.16.13 // indirect

+ 137 - 0
internal/api/api.go

@@ -57,6 +57,8 @@ func New(db *database.DB, addr string) *Server {
 	mux.HandleFunc("/api/clients", s.handleClients)
 	mux.HandleFunc("/api/mqtt", s.handleMQTT)
 	mux.HandleFunc("/api/mqtt/", s.handleMQTTByID)
+	mux.HandleFunc("/api/ble", s.handleBLE)
+	mux.HandleFunc("/api/ble/", s.handleBLEByID)
 	mux.HandleFunc("/api/health", s.handleHealth)
 	mux.HandleFunc("/ws", s.handleWebSocket)
 	mux.HandleFunc("/", web.Handler())
@@ -165,6 +167,42 @@ func (s *Server) handleMQTTByID(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+func (s *Server) handleBLE(w http.ResponseWriter, r *http.Request) {
+	logger.Debug("HTTP %s %s", r.Method, r.URL.Path)
+	switch r.Method {
+	case http.MethodGet:
+		s.listBLEConfigs(w, r)
+	case http.MethodPost:
+		s.createBLEConfig(w, r)
+	default:
+		logger.Warn("不支持的 HTTP 方法: %s %s", r.Method, r.URL.Path)
+		writeJSON(w, http.StatusMethodNotAllowed, Response{Code: -1, Message: "方法不允许"})
+	}
+}
+
+func (s *Server) handleBLEByID(w http.ResponseWriter, r *http.Request) {
+	logger.Debug("HTTP %s %s", r.Method, r.URL.Path)
+	idStr := strings.TrimPrefix(r.URL.Path, "/api/ble/")
+	id, err := strconv.Atoi(idStr)
+	if err != nil {
+		logger.Warn("无效的配置 ID: %s", idStr)
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "无效的 ID"})
+		return
+	}
+
+	switch r.Method {
+	case http.MethodGet:
+		s.getBLEConfig(w, id)
+	case http.MethodPut:
+		s.updateBLEConfig(w, r, id)
+	case http.MethodDelete:
+		s.deleteBLEConfig(w, id)
+	default:
+		logger.Warn("不支持的 HTTP 方法: %s %s", r.Method, r.URL.Path)
+		writeJSON(w, http.StatusMethodNotAllowed, Response{Code: -1, Message: "方法不允许"})
+	}
+}
+
 func (s *Server) listMQTTConfigs(w http.ResponseWriter, r *http.Request) {
 	configs, err := s.db.ListMQTTConfigs()
 	if err != nil {
@@ -269,6 +307,105 @@ func (s *Server) deleteMQTTConfig(w http.ResponseWriter, id int) {
 	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "删除成功"})
 }
 
+func (s *Server) listBLEConfigs(w http.ResponseWriter, r *http.Request) {
+	configs, err := s.db.ListBLEConfigs()
+	if err != nil {
+		logger.Error("查询 BLE 配置列表失败: %v", err)
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+	logger.Debug("查询 BLE 配置列表: %d 条", len(configs))
+	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "ok", Data: configs})
+}
+
+func (s *Server) createBLEConfig(w http.ResponseWriter, r *http.Request) {
+	var cfg database.BLEConfig
+	if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
+		logger.Warn("创建 BLE 配置请求体解析失败: %v", err)
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "无效的请求体"})
+		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 err := s.db.SaveBLEConfig(&cfg); err != nil {
+		logger.Error("创建 BLE 配置失败: %v", err)
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+
+	logger.Info("BLE 配置已创建: id=%d, device=%s", cfg.ID, cfg.DeviceName)
+	writeJSON(w, http.StatusCreated, Response{Code: 0, Message: "创建成功", Data: cfg})
+}
+
+func (s *Server) getBLEConfig(w http.ResponseWriter, id int) {
+	configs, err := s.db.ListBLEConfigs()
+	if err != nil {
+		logger.Error("查询 BLE 配置失败: id=%d, %v", id, err)
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+
+	for _, cfg := range configs {
+		if cfg.ID == id {
+			logger.Debug("查询 BLE 配置: id=%d", id)
+			writeJSON(w, http.StatusOK, Response{Code: 0, Message: "ok", Data: cfg})
+			return
+		}
+	}
+
+	logger.Warn("BLE 配置不存在: id=%d", id)
+	writeJSON(w, http.StatusNotFound, Response{Code: -1, Message: "配置不存在"})
+}
+
+func (s *Server) updateBLEConfig(w http.ResponseWriter, r *http.Request, id int) {
+	var cfg database.BLEConfig
+	if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
+		logger.Warn("更新 BLE 配置请求体解析失败: id=%d, %v", id, err)
+		writeJSON(w, http.StatusBadRequest, Response{Code: -1, Message: "无效的请求体"})
+		return
+	}
+
+	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 err := s.db.SaveBLEConfig(&cfg); err != nil {
+		logger.Error("更新 BLE 配置失败: id=%d, %v", id, err)
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+
+	logger.Info("BLE 配置已更新: id=%d, device=%s", id, cfg.DeviceName)
+	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "更新成功", Data: cfg})
+}
+
+func (s *Server) deleteBLEConfig(w http.ResponseWriter, id int) {
+	if err := s.db.DeleteBLEConfig(id); err != nil {
+		logger.Error("删除 BLE 配置失败: id=%d, %v", id, err)
+		writeJSON(w, http.StatusInternalServerError, Response{Code: -1, Message: err.Error()})
+		return
+	}
+	logger.Info("BLE 配置已删除: id=%d", id)
+	writeJSON(w, http.StatusOK, Response{Code: 0, Message: "删除成功"})
+}
+
 func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) {
 	w.Header().Set("Content-Type", "application/json")
 	w.WriteHeader(statusCode)

+ 46 - 24
internal/database/database.go

@@ -28,9 +28,11 @@ type MQTTConfig struct {
 }
 
 type BLEConfig struct {
-	ID         int    `json:"id"`
-	DeviceName string `json:"device_name"`
-	Enabled    bool   `json:"enabled"`
+	ID          int    `json:"id"`
+	DeviceName  string `json:"device_name"`
+	ServiceUUID string `json:"service_uuid"`
+	CharUUID    string `json:"char_uuid"`
+	Enabled     bool   `json:"enabled"`
 }
 
 func New(dbPath string) (*DB, error) {
@@ -72,11 +74,6 @@ func (d *DB) init() error {
 		topic TEXT NOT NULL,
 		enabled BOOLEAN DEFAULT 1
 	);
-	CREATE TABLE IF NOT EXISTS ble_config (
-		id INTEGER PRIMARY KEY AUTOINCREMENT,
-		device_name TEXT NOT NULL DEFAULT 'AI-Light',
-		enabled BOOLEAN DEFAULT 1
-	);
 	`
 	_, err := d.conn.ExecContext(context.Background(), query)
 	if err != nil {
@@ -87,6 +84,21 @@ func (d *DB) init() error {
 		alterQuery := fmt.Sprintf("ALTER TABLE mqtt_config ADD COLUMN %s TEXT DEFAULT ''", col)
 		d.conn.ExecContext(context.Background(), alterQuery)
 	}
+
+	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',
+		enabled BOOLEAN DEFAULT 1
+	);
+	`
+	_, err = d.conn.ExecContext(context.Background(), bleQuery)
+	if err != nil {
+		return err
+	}
+
 	return nil
 }
 
@@ -158,12 +170,20 @@ func (d *DB) ListMQTTConfigs() ([]MQTTConfig, error) {
 	return configs, nil
 }
 
+func (d *DB) Close() error {
+	logger.Debug("数据库连接已关闭")
+	if err := d.conn.Close(); err != nil {
+		return err
+	}
+	return d.db.Close()
+}
+
 func (d *DB) GetBLEConfig() (*BLEConfig, error) {
-	query := "SELECT id, device_name, enabled FROM ble_config WHERE enabled = 1 LIMIT 1"
+	query := "SELECT id, device_name, service_uuid, char_uuid, enabled FROM ble_config WHERE enabled = 1 LIMIT 1"
 	row := d.conn.QueryRowContext(context.Background(), query)
 
 	var cfg BLEConfig
-	err := row.Scan(&cfg.ID, &cfg.DeviceName, &cfg.Enabled)
+	err := row.Scan(&cfg.ID, &cfg.DeviceName, &cfg.ServiceUUID, &cfg.CharUUID, &cfg.Enabled)
 	if err == sql.ErrNoRows {
 		logger.Debug("未找到启用的 BLE 配置")
 		return nil, nil
@@ -177,16 +197,26 @@ 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, enabled) VALUES (?, ?)"
-		_, err := d.conn.ExecContext(context.Background(), query, cfg.DeviceName, cfg.Enabled)
+		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)
 		if err != nil {
 			logger.Error("插入 BLE 配置失败: %v", err)
 		}
 		return err
 	}
-	query := "UPDATE ble_config SET device_name = ?, enabled = ? WHERE id = ?"
-	_, err := d.conn.ExecContext(context.Background(), query, cfg.DeviceName, cfg.Enabled, cfg.ID)
+	query := "UPDATE ble_config SET device_name = ?, service_uuid = ?, char_uuid = ?, enabled = ? WHERE id = ?"
+	_, err := d.conn.ExecContext(context.Background(), query, cfg.DeviceName, cfg.ServiceUUID, cfg.CharUUID, cfg.Enabled, cfg.ID)
 	if err != nil {
 		logger.Error("更新 BLE 配置失败: id=%d, %v", cfg.ID, err)
 	}
@@ -205,7 +235,7 @@ func (d *DB) DeleteBLEConfig(id int) error {
 }
 
 func (d *DB) ListBLEConfigs() ([]BLEConfig, error) {
-	query := "SELECT id, device_name, enabled FROM ble_config ORDER BY id"
+	query := "SELECT id, device_name, service_uuid, char_uuid, enabled FROM ble_config ORDER BY id"
 	rows, err := d.conn.QueryContext(context.Background(), query)
 	if err != nil {
 		logger.Error("查询 BLE 配置列表失败: %v", err)
@@ -216,7 +246,7 @@ func (d *DB) ListBLEConfigs() ([]BLEConfig, error) {
 	var configs []BLEConfig
 	for rows.Next() {
 		var cfg BLEConfig
-		if err := rows.Scan(&cfg.ID, &cfg.DeviceName, &cfg.Enabled); err != nil {
+		if err := rows.Scan(&cfg.ID, &cfg.DeviceName, &cfg.ServiceUUID, &cfg.CharUUID, &cfg.Enabled); err != nil {
 			logger.Warn("扫描 BLE 配置行失败: %v", err)
 			continue
 		}
@@ -225,11 +255,3 @@ func (d *DB) ListBLEConfigs() ([]BLEConfig, error) {
 	logger.Debug("查询到 %d 条 BLE 配置", len(configs))
 	return configs, nil
 }
-
-func (d *DB) Close() error {
-	logger.Debug("数据库连接已关闭")
-	if err := d.conn.Close(); err != nil {
-		return err
-	}
-	return d.db.Close()
-}

+ 115 - 0
scripts/ble_relay.py

@@ -0,0 +1,115 @@
+"""
+AI-Light BLE Relay
+从stdin读取JSON状态消息,通过BLE发送到AI-Light设备
+
+用法:
+    python ble_relay.py [选项]
+
+选项:
+    --device         蓝牙设备名称 (默认: AI-Light)
+    --service-uuid   服务UUID (默认: b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001)
+    --char-uuid      特征UUID (默认: b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001)
+"""
+
+import sys
+import asyncio
+import json
+import argparse
+import logging
+
+from bleak import BleakClient, BleakScanner
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [BLE] %(message)s",
+    datefmt="%H:%M:%S"
+)
+logger = logging.getLogger(__name__)
+
+STATUS_MAP = {
+    "idle": "idle",
+    "busy": "busy",
+    "retry": "thinking",
+    "pending": "thinking",
+    "reasoning": "thinking",
+    "using_tool": "ai",
+    "running": "ai",
+    # "completed" 和 "session_completed" 跳动太快,不发送到灯
+    "permission": "alarm",
+    "error": "error"
+}
+
+
+async def find_device(device_name: str):
+    logger.info(f"Searching for {device_name}...")
+    device = await BleakScanner.find_device_by_name(device_name, timeout=10.0)
+    if device is None:
+        logger.error(f"Device '{device_name}' not found")
+        return None
+    logger.info(f"Found: {device.name} ({device.address})")
+    return device
+
+
+async def run_relay(device_name: str, service_uuid: str, char_uuid: str):
+    device = await find_device(device_name)
+    if not device:
+        sys.exit(1)
+
+    async with BleakClient(device) as client:
+        logger.info(f"Connected to {device.name}")
+
+        for line in sys.stdin:
+            try:
+                line = line.strip()
+                if not line:
+                    continue
+
+                data = json.loads(line)
+                code = data.get("code", "idle")
+                mode = STATUS_MAP.get(code)
+                if mode is None:
+                    continue
+
+                await client.write_gatt_char(char_uuid, mode.encode("utf-8"))
+                logger.info(f"Sent: {mode} (from code: {code})")
+
+            except json.JSONDecodeError:
+                logger.warning(f"Invalid JSON: {line}")
+            except Exception as e:
+                logger.error(f"Error: {e}")
+                # Try to reconnect
+                logger.info("Attempting to reconnect...")
+                device = await find_device(device_name)
+                if not device:
+                    logger.error("Reconnection failed")
+                    break
+                client = BleakClient(device)
+                await client.__aenter__()
+                logger.info("Reconnected")
+
+
+def main():
+    parser = argparse.ArgumentParser(description="AI-Light BLE Relay")
+    parser.add_argument("--device", default="AI-Light",
+                        help="BLE device name (default: AI-Light)")
+    parser.add_argument("--service-uuid", default="b8b7e001-7a6b-4f4f-9a8b-11c0ffee0001",
+                        help="BLE service UUID")
+    parser.add_argument("--char-uuid", default="b8b7e002-7a6b-4f4f-9a8b-11c0ffee0001",
+                        help="BLE characteristic UUID")
+    args = parser.parse_args()
+
+    logger.info(f"BLE Relay starting...")
+    logger.info(f"Device: {args.device}")
+    logger.info(f"Waiting for status messages on stdin...")
+
+    try:
+        asyncio.run(run_relay(args.device, args.service_uuid, args.char_uuid))
+    except KeyboardInterrupt:
+        logger.info("Stopped")
+    except Exception as e:
+        logger.error(f"Fatal error: {e}")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()

+ 64 - 1
scripts/build.bat

@@ -4,7 +4,69 @@ setlocal
 set BINARY_NAME=opencode-monitor
 set VERSION=dev
 
+if "%1"=="--only-ble" goto only_ble
+if "%1"=="--ble" goto ble
+goto all_platforms
+
+:only_ble
+echo === Building BLE relay only ===
+python -m pip install pyinstaller bleak
+if not exist bin mkdir bin
+python -m PyInstaller --onefile --name ble_relay --distpath bin --specpath build --workpath build scripts\ble_relay.py
+copy /Y bin\ble_relay.exe cmd\monitor\ble_relay.exe >nul
+echo.
+echo Done: bin\ble_relay.exe + cmd\monitor\ble_relay.exe
+goto end
+
+:ble
+echo === Building current platform with BLE ===
+echo Step 1: Building BLE relay...
+python -m pip install pyinstaller bleak
+if not exist bin mkdir bin
+python -m PyInstaller --onefile --name ble_relay --distpath bin --specpath build --workpath build scripts\ble_relay.py
+copy /Y bin\ble_relay.exe cmd\monitor\ble_relay.exe >nul
+echo Step 2: Building Go binary with BLE embedded...
+go mod tidy
+if not exist dist mkdir dist
+
+REM Detect architecture
+if "%PROCESSOR_ARCHITECTURE%"=="ARM64" (
+    set GOARCH=arm64
+    set SUFFIX=-windows-arm64.exe
+) else (
+    set GOARCH=amd64
+    set SUFFIX=-windows-amd64.exe
+)
+set GOOS=windows
+
+go build -tags ble -ldflags "-X main.Version=%VERSION%" -o dist\%BINARY_NAME%%SUFFIX% .\cmd\monitor
+del /Q cmd\monitor\ble_relay.exe 2>nul
+echo.
+echo Done: dist\%BINARY_NAME%%SUFFIX%
+goto end
+
+:all_platforms
+echo === Building all platforms (no BLE) ===
 if not exist dist mkdir dist
+go mod tidy
+
+echo Building for Linux (amd64)...
+set GOOS=linux
+set GOARCH=amd64
+go build -ldflags "-X main.Version=%VERSION%" -o dist\%BINARY_NAME%-linux-amd64 .\cmd\monitor
+
+echo Building for Linux (arm64)...
+set GOARCH=arm64
+go build -ldflags "-X main.Version=%VERSION%" -o dist\%BINARY_NAME%-linux-arm64 .\cmd\monitor
+
+echo Building for macOS (amd64)...
+set GOOS=darwin
+set GOARCH=amd64
+go build -ldflags "-X main.Version=%VERSION%" -o dist\%BINARY_NAME%-darwin-amd64 .\cmd\monitor
+
+echo Building for macOS (arm64)...
+set GOARCH=arm64
+go build -ldflags "-X main.Version=%VERSION%" -o dist\%BINARY_NAME%-darwin-arm64 .\cmd\monitor
 
 echo Building for Windows (amd64)...
 set GOOS=windows
@@ -16,7 +78,8 @@ set GOARCH=arm64
 go build -ldflags "-X main.Version=%VERSION%" -o dist\%BINARY_NAME%-windows-arm64.exe .\cmd\monitor
 
 echo.
-echo Build complete! Binaries in dist\
+echo Done! Binaries in dist\
 dir dist\
 
+:end
 endlocal

+ 69 - 26
scripts/build.sh

@@ -1,41 +1,84 @@
 #!/bin/bash
-
 set -e
 
+BINARY_NAME="opencode-monitor"
+VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
 PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
 
-# 构建前端(可选,需配置 WEB_DIR 环境变量指向前端项目目录)
-WEB_DIR="${WEB_DIR:-}"
-HTML_DIR="$PROJECT_DIR/internal/web/html"
+cd "$PROJECT_DIR"
 
-if [ -n "$WEB_DIR" ] && [ -d "$WEB_DIR" ]; then
-    echo "Building frontend..."
-    cd "$WEB_DIR" && npm run build
-    rm -rf "$HTML_DIR"
-    cp -r dist "$HTML_DIR"
-    echo "Frontend built and copied."
-fi
+detect_platform() {
+    local os arch suffix
+    os=$(uname -s | tr '[:upper:]' '[:lower:]')
+    arch=$(uname -m)
+    case "$os" in
+        linux*)  os="linux" ;;
+        darwin*) os="darwin" ;;
+        *)       os="windows" ;;
+    esac
+    case "$arch" in
+        x86_64|amd64) arch="amd64" ;;
+        aarch64|arm64) arch="arm64" ;;
+        *)             arch="amd64" ;;
+    esac
+    if [ "$os" = "windows" ]; then
+        suffix=".exe"
+    else
+        suffix=""
+    fi
+    echo "${os}-${arch}${suffix}"
+}
 
-cd "$PROJECT_DIR"
+if [ "$1" = "--only-ble" ]; then
+    echo "=== Building BLE relay only ==="
+    python3 -m pip install pyinstaller bleak || python -m pip install pyinstaller bleak
+    mkdir -p bin
+    python3 -m PyInstaller --onefile --name ble_relay --distpath bin --specpath build --workpath build scripts/ble_relay.py || python -m PyInstaller --onefile --name ble_relay --distpath bin --specpath build --workpath build scripts/ble_relay.py
+    cp bin/ble_relay cmd/monitor/ble_relay
+    echo ""
+    echo "Done: bin/ble_relay + cmd/monitor/ble_relay"
 
-BINARY_NAME="opencode-monitor"
-VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
+elif [ "$1" = "--ble" ]; then
+    PLATFORM=$(detect_platform)
+    echo "=== Building for ${PLATFORM} with BLE ==="
+    echo "Step 1: Building BLE relay..."
+    python3 -m pip install pyinstaller bleak || python -m pip install pyinstaller bleak
+    mkdir -p bin
+    python3 -m PyInstaller --onefile --name ble_relay --distpath bin --specpath build --workpath build scripts/ble_relay.py || python -m PyInstaller --onefile --name ble_relay --distpath bin --specpath build --workpath build scripts/ble_relay.py
+    cp bin/ble_relay cmd/monitor/ble_relay
+    echo "Step 2: Building Go binary with BLE embedded..."
+    go mod tidy
+    mkdir -p dist
+    go build -tags ble -ldflags "-X main.Version=${VERSION}" -o "dist/${BINARY_NAME}-${PLATFORM}" ./cmd/monitor
+    rm -f cmd/monitor/ble_relay
+    echo ""
+    echo "Done: dist/${BINARY_NAME}-${PLATFORM}"
+
+else
+    echo "=== Building all platforms (no BLE) ==="
+    mkdir -p dist
+    go mod tidy
 
-mkdir -p dist
+    echo "Building for Linux (amd64)..."
+    GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-linux-amd64 ./cmd/monitor
 
-echo "Building for Linux (amd64)..."
-GOOS=linux GOARCH=amd64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-linux-amd64 ./cmd/monitor
+    echo "Building for Linux (arm64)..."
+    GOOS=linux GOARCH=arm64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-linux-arm64 ./cmd/monitor
 
-echo "Building for Linux (arm64)..."
-GOOS=linux GOARCH=arm64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-linux-arm64 ./cmd/monitor
+    echo "Building for macOS (amd64)..."
+    GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-darwin-amd64 ./cmd/monitor
 
-echo "Building for Windows (amd64)..."
-GOOS=windows GOARCH=amd64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-windows-amd64.exe ./cmd/monitor
+    echo "Building for macOS (arm64)..."
+    GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-darwin-arm64 ./cmd/monitor
 
-echo "Building for Windows (arm64)..."
-GOOS=windows GOARCH=arm64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-windows-arm64.exe ./cmd/monitor
+    echo "Building for Windows (amd64)..."
+    GOOS=windows GOARCH=amd64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-windows-amd64.exe ./cmd/monitor
 
-echo ""
-echo "Build complete! Binaries in dist/"
-ls -la dist/
+    echo "Building for Windows (arm64)..."
+    GOOS=windows GOARCH=arm64 go build -ldflags "-X main.Version=${VERSION}" -o dist/${BINARY_NAME}-windows-arm64.exe ./cmd/monitor
+
+    echo ""
+    echo "Done! Binaries in dist/"
+    ls -la dist/
+fi

+ 1 - 0
scripts/requirements.txt

@@ -0,0 +1 @@
+bleak>=0.21.0