Ver código fonte

feat: 添加BLE蓝牙中继功能

- 新增 scripts/ble_relay.py: Python蓝牙中继脚本,从stdin读取状态通过BLE发送
- 新增 scripts/requirements.txt: Python依赖 (bleak)
- 修改 database.go: 添加ble_config表和CRUD方法
- 修改 api.go: 添加BLE配置API端点 (/api/ble)
- 修改 main.go:
  - 添加BLE中继启动逻辑,自动根据配置启动
  - 添加config ble子命令管理BLE配置
  - 支持MQTT和BLE同时工作
Moki 2 semanas atrás
pai
commit
e458a4f8f1
5 arquivos alterados com 527 adições e 12 exclusões
  1. 174 12
      cmd/monitor/main.go
  2. 137 0
      internal/api/api.go
  3. 101 0
      internal/database/database.go
  4. 114 0
      scripts/ble_relay.py
  5. 1 0
      scripts/requirements.txt

+ 174 - 12
cmd/monitor/main.go

@@ -4,7 +4,9 @@ import (
 	"context"
 	"flag"
 	"fmt"
+	"io"
 	"os"
+	"os/exec"
 	"os/signal"
 	"sort"
 	"strconv"
@@ -52,7 +54,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("")
@@ -103,11 +105,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)
@@ -184,6 +191,17 @@ func runMonitor(args []string) {
 		logger.Info("未配置 MQTT,跳过 MQTT 连接")
 	}
 
+	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 {
+		bleStdin = startBLERelay(bleCfg, ctx)
+	} else {
+		logger.Info("未配置 BLE,跳过 BLE 中继")
+	}
+
 	var apiServer *api.Server
 	if *apiAddr != "" {
 		apiServer = api.New(db, *apiAddr)
@@ -209,7 +227,7 @@ func runMonitor(args []string) {
 		}()
 	}
 
-	callback := createCallback(mqttClient, apiServer)
+	callback := createCallback(mqttClient, apiServer, bleStdin)
 
 	if *portsFlag != "" {
 		runFixedMode(ctx, *host, *portsFlag, callback, sigChan)
@@ -352,6 +370,9 @@ func runConfig(args []string) {
 		logger.Info("MQTT 配置已删除: id=%d", id)
 		fmt.Println("配置已删除")
 
+	case "ble":
+		runBleConfig(db, args[1:])
+
 	default:
 		printConfigUsage()
 	}
@@ -361,23 +382,158 @@ func printConfigUsage() {
 	fmt.Println("用法: opencode-monitor config <子命令> [选项]")
 	fmt.Println("")
 	fmt.Println("子命令:")
-	fmt.Println("  list              列出所有配置")
+	fmt.Println("  list              列出所有 MQTT 配置")
 	fmt.Println("  set               设置 MQTT 配置")
-	fmt.Println("  delete <id>       删除配置")
+	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("选项:")
+	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 createCallback(mqttClient *mqttcli.Client, apiServer *api.Server) monitor.EventCallback {
+func startBLERelay(bleCfg *database.BLEConfig, ctx context.Context) io.WriteCloser {
+	args := []string{
+		"scripts/ble_relay.py",
+		"--device", bleCfg.DeviceName,
+		"--service-uuid", bleCfg.ServiceUUID,
+		"--char-uuid", bleCfg.CharUUID,
+	}
+
+	cmd := exec.CommandContext(ctx, "python", 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)
+			fmt.Printf("查询失败: %v\n", err)
+			return
+		}
+		if len(configs) == 0 {
+			fmt.Println("未配置 BLE")
+			return
+		}
+		logger.Info("查询到 %d 条 BLE 配置", len(configs))
+		for _, cfg := range configs {
+			status := "禁用"
+			if cfg.Enabled {
+				status = "启用"
+			}
+			fmt.Printf("[%d] %s | %s | %s | %s\n", cfg.ID, cfg.DeviceName, cfg.ServiceUUID, cfg.CharUUID, status)
+		}
+
+	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,
+			ServiceUUID: *serviceUUID,
+			CharUUID:    *charUUID,
+			Enabled:     *enabled,
+		}
+
+		if err := db.SaveBLEConfig(cfg); err != nil {
+			logger.Error("保存 BLE 配置失败: %v", err)
+			fmt.Printf("保存失败: %v\n", err)
+			return
+		}
+		logger.Info("BLE 配置已保存: %s", cfg.DeviceName)
+		fmt.Println("配置已保存")
+
+	case "delete":
+		if len(args) < 2 {
+			fmt.Println("必须指定配置 ID")
+			return
+		}
+		id, err := strconv.Atoi(args[1])
+		if err != nil {
+			logger.Warn("无效的配置 ID: %s", args[1])
+			fmt.Println("无效的 ID")
+			return
+		}
+		if err := db.DeleteBLEConfig(id); err != nil {
+			logger.Error("删除 BLE 配置失败: id=%d, %v", id, err)
+			fmt.Printf("删除失败: %v\n", err)
+			return
+		}
+		logger.Info("BLE 配置已删除: id=%d", id)
+		fmt.Println("配置已删除")
+
+	default:
+		printBleConfigUsage()
+	}
+}
+
+func printBleConfigUsage() {
+	fmt.Println("用法: opencode-monitor config ble <子命令> [选项]")
+	fmt.Println("")
+	fmt.Println("子命令:")
+	fmt.Println("  list              列出所有 BLE 配置")
+	fmt.Println("  set               设置 BLE 配置")
+	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("  --enabled         是否启用 (true/false)")
+}
+
+func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server, bleStdin io.Writer) monitor.EventCallback {
 	lastStatus := make(map[int]string)
 	var mu sync.Mutex
 
@@ -397,6 +553,12 @@ func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server) monitor.E
 		if apiServer != nil {
 			apiServer.BroadcastStatus(port, status, code)
 		}
+		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)
+			}
+		}
 	}
 
 	return func(port int, evt *event.SSEEvent) {
@@ -405,7 +567,7 @@ func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server) monitor.E
 			fmt.Println(msg)
 		}
 
-		if mqttClient == nil && apiServer == nil {
+		if mqttClient == nil && apiServer == nil && bleStdin == nil {
 			return
 		}
 

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

+ 101 - 0
internal/database/database.go

@@ -27,6 +27,14 @@ type MQTTConfig struct {
 	Enabled  bool   `json:"enabled"`
 }
 
+type BLEConfig struct {
+	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) {
 	dir := filepath.Dir(dbPath)
 	if err := os.MkdirAll(dir, 0755); err != nil {
@@ -76,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
 }
 
@@ -154,3 +177,81 @@ func (d *DB) Close() error {
 	}
 	return d.db.Close()
 }
+
+func (d *DB) GetBLEConfig() (*BLEConfig, error) {
+	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.ServiceUUID, &cfg.CharUUID, &cfg.Enabled)
+	if err == sql.ErrNoRows {
+		logger.Debug("未找到启用的 BLE 配置")
+		return nil, nil
+	}
+	if err != nil {
+		logger.Error("查询 BLE 配置失败: %v", err)
+		return nil, fmt.Errorf("查询配置失败: %w", err)
+	}
+	logger.Debug("获取到 BLE 配置: id=%d, device=%s", cfg.ID, cfg.DeviceName)
+	return &cfg, nil
+}
+
+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)
+		if err != nil {
+			logger.Error("插入 BLE 配置失败: %v", err)
+		}
+		return err
+	}
+	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)
+	}
+	return err
+}
+
+func (d *DB) DeleteBLEConfig(id int) error {
+	query := "DELETE FROM ble_config WHERE id = ?"
+	_, err := d.conn.ExecContext(context.Background(), query, id)
+	if err != nil {
+		logger.Error("删除 BLE 配置失败: id=%d, %v", id, err)
+	} else {
+		logger.Debug("BLE 配置已删除: id=%d", id)
+	}
+	return err
+}
+
+func (d *DB) ListBLEConfigs() ([]BLEConfig, error) {
+	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)
+		return nil, err
+	}
+	defer rows.Close()
+
+	var configs []BLEConfig
+	for rows.Next() {
+		var cfg BLEConfig
+		if err := rows.Scan(&cfg.ID, &cfg.DeviceName, &cfg.ServiceUUID, &cfg.CharUUID, &cfg.Enabled); err != nil {
+			logger.Warn("扫描 BLE 配置行失败: %v", err)
+			continue
+		}
+		configs = append(configs, cfg)
+	}
+	logger.Debug("查询到 %d 条 BLE 配置", len(configs))
+	return configs, nil
+}

+ 114 - 0
scripts/ble_relay.py

@@ -0,0 +1,114 @@
+"""
+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": "success",
+    "session_completed": "success",
+    "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, "idle")
+
+                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()

+ 1 - 0
scripts/requirements.txt

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