ソースを参照

添加蓝牙支持

moki 2 週間 前
コミット
32d1f3a1f4
8 ファイル変更627 行追加18 行削除
  1. 89 5
      cmd/monitor/main.go
  2. 9 4
      go.mod
  3. 42 9
      go.sum
  4. 66 0
      internal/ble/ble.go
  5. 278 0
      internal/ble/ble_linux.go
  6. 32 0
      internal/ble/ble_stub.go
  7. 32 0
      internal/ble/ble_windows.go
  8. 79 0
      internal/database/database.go

+ 89 - 5
cmd/monitor/main.go

@@ -14,6 +14,7 @@ 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"
@@ -184,6 +185,19 @@ func runMonitor(args []string) {
 		logger.Info("未配置 MQTT,跳过 MQTT 连接")
 	}
 
+	var bleClient *ble.Client
+	bleCfg, err := db.GetBLEConfig()
+	if err != nil {
+		logger.Error("读取 BLE 配置失败: %v", 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)
+	} else {
+		logger.Info("未配置 BLE,跳过蓝牙连接")
+	}
+
 	var apiServer *api.Server
 	if *apiAddr != "" {
 		apiServer = api.New(db, *apiAddr)
@@ -209,7 +223,7 @@ func runMonitor(args []string) {
 		}()
 	}
 
-	callback := createCallback(mqttClient, apiServer)
+	callback := createCallback(mqttClient, apiServer, bleClient)
 
 	if *portsFlag != "" {
 		runFixedMode(ctx, *host, *portsFlag, callback, sigChan)
@@ -352,6 +366,63 @@ func runConfig(args []string) {
 		logger.Info("MQTT 配置已删除: id=%d", id)
 		fmt.Println("配置已删除")
 
+	case "ble-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
+		}
+		for _, cfg := range configs {
+			status := "禁用"
+			if cfg.Enabled {
+				status = "启用"
+			}
+			fmt.Printf("[%d] %s | %s\n", cfg.ID, cfg.DeviceName, status)
+		}
+
+	case "ble-set":
+		fs := flag.NewFlagSet("config ble-set", flag.ExitOnError)
+		deviceName := fs.String("device", ble.DefaultDeviceName, "蓝牙设备名称")
+		enabled := fs.Bool("enabled", true, "是否启用")
+		fs.Parse(args[1:])
+
+		cfg := &database.BLEConfig{
+			DeviceName: *deviceName,
+			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("BLE 配置已保存")
+
+	case "ble-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])
+			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("BLE 配置已删除")
+
 	default:
 		printConfigUsage()
 	}
@@ -361,23 +432,32 @@ 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("  --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 createCallback(mqttClient *mqttcli.Client, apiServer *api.Server, bleClient *ble.Client) monitor.EventCallback {
 	lastStatus := make(map[int]string)
 	var mu sync.Mutex
 
@@ -397,6 +477,10 @@ func createCallback(mqttClient *mqttcli.Client, apiServer *api.Server) monitor.E
 		if apiServer != nil {
 			apiServer.BroadcastStatus(port, status, code)
 		}
+		if bleClient != nil {
+			mode := ble.MapCodeToMode(code)
+			bleClient.SetMode(mode)
+		}
 	}
 
 	return func(port int, evt *event.SSEEvent) {

+ 9 - 4
go.mod

@@ -4,22 +4,27 @@ 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/gorilla/websocket v1.5.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
-	golang.org/x/mod v0.3.0 // 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/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.0.0-20201124115921-2c860bdd6e78 // indirect
-	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+	golang.org/x/tools v0.1.11 // indirect
 	lukechampine.com/uint128 v1.2.0 // indirect
 	modernc.org/cc/v3 v3.40.0 // indirect
 	modernc.org/ccgo/v3 v3.16.13 // indirect

+ 42 - 9
go.sum

@@ -1,57 +1,90 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
 github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
+github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
+github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
+github.com/godbus/dbus/v5 v5.0.3 h1:ZqHaoEF7TBzh4jzPmqVhE/5A1z9of6orkAe5uHoAeME=
+github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
 github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
 github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e h1:1Sc4DqlgszKejMkjydCSq8zOKmF+hr8odAl5JoBZ+ec=
+github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e/go.mod h1:dMCjicU6vRBk34dqOmIZm0aod6gUwZXOXzBROqGous0=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3S+TUwg0tYQjid0q1txyNUJuuSmz8Kdk=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q=
+github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/suapapa/go_eddystone v1.3.1/go.mod h1:bXC11TfJOS+3g3q/Uzd7FKd5g62STQEfeEIhcKe4Qy8=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
-golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
+golang.org/x/tools v0.1.11 h1:loJ25fNOEhSXfHrpoGj91eCUThwdNX6u24rO1xnNteY=
+golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
 lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
 modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=

+ 66 - 0
internal/ble/ble.go

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

+ 278 - 0
internal/ble/ble_linux.go

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

+ 32 - 0
internal/ble/ble_stub.go

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

+ 32 - 0
internal/ble/ble_windows.go

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

+ 79 - 0
internal/database/database.go

@@ -27,6 +27,12 @@ type MQTTConfig struct {
 	Enabled  bool   `json:"enabled"`
 }
 
+type BLEConfig struct {
+	ID         int    `json:"id"`
+	DeviceName string `json:"device_name"`
+	Enabled    bool   `json:"enabled"`
+}
+
 func New(dbPath string) (*DB, error) {
 	dir := filepath.Dir(dbPath)
 	if err := os.MkdirAll(dir, 0755); err != nil {
@@ -66,6 +72,11 @@ 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 {
@@ -147,6 +158,74 @@ func (d *DB) ListMQTTConfigs() ([]MQTTConfig, error) {
 	return configs, nil
 }
 
+func (d *DB) GetBLEConfig() (*BLEConfig, error) {
+	query := "SELECT id, device_name, 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)
+	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.ID == 0 {
+		query := "INSERT INTO ble_config (device_name, enabled) VALUES (?, ?)"
+		_, err := d.conn.ExecContext(context.Background(), query, cfg.DeviceName, 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)
+	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, 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.Enabled); err != nil {
+			logger.Warn("扫描 BLE 配置行失败: %v", err)
+			continue
+		}
+		configs = append(configs, cfg)
+	}
+	logger.Debug("查询到 %d 条 BLE 配置", len(configs))
+	return configs, nil
+}
+
 func (d *DB) Close() error {
 	logger.Debug("数据库连接已关闭")
 	if err := d.conn.Close(); err != nil {