diff --git a/internal/plugins/remotedevice/binary_test.go b/internal/plugins/remotedevice/binary_test.go index 7dbc68b..49eafe2 100644 --- a/internal/plugins/remotedevice/binary_test.go +++ b/internal/plugins/remotedevice/binary_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" ) @@ -560,3 +561,115 @@ func TestClipboardEndToEnd(t *testing.T) { } }) } + +// ===== 能力矩阵:caps 声明 → 工具可用性 ===== + +func TestCapabilityMatrix(t *testing.T) { + cases := []struct { + name string + caps []string + tool string + expected bool + }{ + {"摄像头只声明camera不能screensee", []string{"camera"}, "screensee", false}, + {"摄像头只声明camera可以camerasue", []string{"camera"}, "camerasue", true}, + {"屏幕设备支持screensue+screensee", []string{"screen"}, "screensee", true}, + {"clipboard能力含读写", []string{"clipboard"}, "clipboardsue", true}, + {"精确声明computeruse", []string{"computeruse"}, "computeruse", true}, + {"历史cmd视为全能力", []string{"status", "cmdrun", "deviceinfo"}, "screensee", true}, + {"无任何已知能力视为全兼容", []string{}, "computeruse", true}, + {"混合:有已知能力则严格匹配", []string{"camera", "screen"}, "computeruse", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := deviceSupportsTool(tc.caps, tc.tool); got != tc.expected { + t.Fatalf("deviceSupportsTool(%v, %s) = %v, want %v", tc.caps, tc.tool, got, tc.expected) + } + }) + } + + // 端到端:声明 camera 的设备调 screensee 应被拒绝 + reg := NewRegistry() + token := "test-cap-token" + reg.SetAcceptToken(func(provided string) bool { return provided == token }) + dev := &devicectlDevice{reg: reg} + + srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS)) + defer srv.Close() + cli := dialTestWS(t, srv.URL, token) + defer cli.close() + + cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-only","name":"纯摄像头","kind":"camera","caps":["camera"]}}`)) + if _, _, err := cli.readMsg(); err != nil { + t.Fatalf("read hello_ack: %v", err) + } + reg.SetAuthorized("cam-only", true) + + if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "cam-only"}); err == nil { + t.Fatal("camera-only device should not support screensee") + } else if !strings.Contains(err.Error(), "未声明 screensee 能力") { + t.Fatalf("unexpected error: %v", err) + } +} + +// ===== 设备主动上报事件 → 事件回调 ===== + +func TestDeviceEventReport(t *testing.T) { + reg := NewRegistry() + token := "test-evt-token" + reg.SetAcceptToken(func(provided string) bool { return provided == token }) + + var events []map[string]interface{} + var evtMu sync.Mutex + reg.SetEventHandler(func(deviceID string, msg map[string]interface{}) { + evtMu.Lock() + events = append(events, msg) + evtMu.Unlock() + }) + + srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS)) + defer srv.Close() + cli := dialTestWS(t, srv.URL, token) + defer cli.close() + + cli.sendText([]byte(`{"op":"hello","device":{"device_id":"cam-watch","name":"监控摄像头","kind":"camera","caps":["camera"]}}`)) + if _, _, err := cli.readMsg(); err != nil { + t.Fatalf("read hello_ack: %v", err) + } + + // 设备主动上报:识别到未知人员驻留 + cli.sendText(mustJSON(map[string]interface{}{ + "op": "event", "device_id": "cam-watch", + "type": "unknown_person_detected", + "detail": "后门区域检测到陌生面孔,驻留超过30秒", + })) + // 不带 device_id 时应回退到当前连接的设备 + cli.sendText(mustJSON(map[string]interface{}{ + "op": "event", + "type": "motion", + })) + + deadline := time.After(3 * time.Second) + for { + evtMu.Lock() + n := len(events) + evtMu.Unlock() + if n >= 2 { + break + } + select { + case <-deadline: + t.Fatalf("expected 2 events, got %d", n) + default: + time.Sleep(20 * time.Millisecond) + } + } + evtMu.Lock() + defer evtMu.Unlock() + if events[0]["type"] != "unknown_person_detected" { + t.Fatalf("unexpected first event: %v", events[0]) + } + if events[1]["type"] != "motion" { + t.Fatalf("unexpected second event: %v", events[1]) + } +} diff --git a/internal/plugins/remotedevice/device.go b/internal/plugins/remotedevice/device.go index 3d3e794..b9f7458 100644 --- a/internal/plugins/remotedevice/device.go +++ b/internal/plugins/remotedevice/device.go @@ -391,6 +391,9 @@ func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, e if !m.Online { return nil, fmt.Errorf("device %s 不在线", id) } + if !d.reg.SupportsTool(id, "screensee") { + return nil, fmt.Errorf("device %s 未声明 screensee 能力(caps=%v),无法截屏", id, m.Caps) + } reqID := newReqID() if err := d.reg.PushCmd(id, reqID, "screensee", "homeagent"); err != nil { return nil, fmt.Errorf("下发截屏命令失败: %w", err) @@ -439,6 +442,9 @@ func (d *devicectlDevice) computeruse(args map[string]interface{}) (interface{}, if !m.Online { return nil, fmt.Errorf("device %s 不在线", id) } + if !d.reg.SupportsTool(id, "computeruse") { + return nil, fmt.Errorf("device %s 未声明 computeruse 能力(caps=%v),无法操控鼠标键盘", id, m.Caps) + } // 构造 GUI 端约定的 JSON 参数(坐标相对 screensueDisplay 所选屏) params := map[string]interface{}{"action": action} @@ -492,8 +498,9 @@ func (d *devicectlDevice) computeruse(args map[string]interface{}) (interface{}, return out, nil } -// clipboardCheck 检查设备可操作性(存在/已授权/在线),返回错误或 nil。 -func (d *devicectlDevice) clipboardCheck(id, verb string) error { +// clipboardCheck 检查设备可操作性(存在/已授权/在线/能力声明),返回错误或 nil。 +// tool 为空时跳过能力校验(如 device_ctl_cmdrun 由自身逻辑处理)。 +func (d *devicectlDevice) clipboardCheck(id, verb, tool string) error { if id == "" { return fmt.Errorf("device_id required") } @@ -507,6 +514,9 @@ func (d *devicectlDevice) clipboardCheck(id, verb string) error { if !m.Online { return fmt.Errorf("device %s 不在线", id) } + if tool != "" && !d.reg.SupportsTool(id, tool) { + return fmt.Errorf("device %s 未声明 %s 能力(caps=%v),无法执行此操作", id, tool, m.Caps) + } return nil } @@ -514,7 +524,7 @@ func (d *devicectlDevice) clipboardCheck(id, verb string) error { // 协议(GUI 配套):homeagent-clipboardsee → 回执 output 字段为剪切板文字。 func (d *devicectlDevice) clipboardsee(args map[string]interface{}) (interface{}, error) { id, _ := args["device_id"].(string) - if err := d.clipboardCheck(id, "读取"); err != nil { + if err := d.clipboardCheck(id, "读取", "clipboardsee"); err != nil { return nil, err } reqID := newReqID() @@ -551,7 +561,7 @@ func (d *devicectlDevice) clipboardsue(args map[string]interface{}) (interface{} if text == "" { return nil, fmt.Errorf("text required(要写入剪切板的内容)") } - if err := d.clipboardCheck(id, "写入"); err != nil { + if err := d.clipboardCheck(id, "写入", "clipboardsue"); err != nil { return nil, err } reqID := newReqID() diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go index b23e74c..090f358 100644 --- a/internal/plugins/remotedevice/plugin.go +++ b/internal/plugins/remotedevice/plugin.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "strings" + "sync" "time" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" @@ -115,6 +116,49 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { log.Printf("[remotedevice] register devicectl channel: %v", err) } + // ---- 设备主动上报事件 → agent 注入 ---------------- + // 摄像头发现异常/传感器报警等场景:设备经 WS op=event 上报, + // 插件将其格式化为文本经 SDK InjectText 异步注入 agent(source=device/{id}, + // 回复路由回 device/{id} 通道),同时发 EventBus 供 WebUI 展示。 + // 节流:同设备同类型事件 10s 内去重,防传感器风暴。 + lastEventAt := map[string]time.Time{} + var eventMu sync.Mutex + p.registry.SetEventHandler(func(deviceID string, msg map[string]interface{}) { + evtType, _ := msg["type"].(string) + if evtType == "" { + evtType = "unknown" + } + key := deviceID + "|" + evtType + eventMu.Lock() + if last, ok := lastEventAt[key]; ok && time.Since(last) < 10*time.Second { + eventMu.Unlock() + log.Printf("[remotedevice] event throttled: %s from %s", evtType, deviceID) + return + } + lastEventAt[key] = time.Now() + eventMu.Unlock() + + // 组装人类可读的事件文本(agent 可直接理解) + detail, _ := msg["detail"].(string) + if detail == "" { + if d, ok := msg["payload"].(map[string]interface{}); ok { + b, _ := json.Marshal(d) + detail = string(b) + } + } + text := fmt.Sprintf("【设备事件上报】设备 %s 触发事件 %s", deviceID, evtType) + if detail != "" { + text += ":" + detail + } + text += "。请关注此事件并按需处理(如通知用户、调用相关工具核实)。" + + log.Printf("[remotedevice] event from %s: %s", deviceID, evtType) + if p.sdk != nil { + // 异步注入:不阻塞 WS 读循环;回复路由回 device/{id} 输出通道 + p.sdk.InjectInput("device/"+deviceID, "device/"+deviceID, "text", map[string]interface{}{"content": text}) + } + }) + // ---- REST 管理面 + WS 设备通道 ---------------- p.registerRoutes() p.server = &http.Server{Addr: p.addr, Handler: p.mux} diff --git a/internal/plugins/remotedevice/registry.go b/internal/plugins/remotedevice/registry.go index 4ec477a..4437876 100644 --- a/internal/plugins/remotedevice/registry.go +++ b/internal/plugins/remotedevice/registry.go @@ -43,6 +43,7 @@ type Registry struct { conns map[string]*wconn // deviceID -> 活跃连接(支持 push) onlineCh chan string onStatus func(msg map[string]interface{}) + onEvent func(deviceID string, msg map[string]interface{}) acceptFn func(token string) bool cmdPending map[string]chan map[string]interface{} // reqID -> 结果 channel results map[string]resultEntry // reqID -> 已留档结果 @@ -54,6 +55,69 @@ type resultEntry struct { Time time.Time } +// ===== 能力矩阵:caps 声明 → 工具可用性 ===== +// 设备 hello 时声明自身能力(caps),服务端据此校验工具调用: +// 摄像头只声明 camera 就不能被调 screensee/computeruse,避免无效下发。 +// 兼容历史值:cmd/cmdrun 视为 shell 命令能力;未声明任何已知能力的设备 +// (如旧版 GUI/waiter)视为全能力,保持向后兼容。 +var capabilityTools = map[string][]string{ + // 屏幕显示/查看 + "screen": {"screensue", "screensee"}, + "screensue": {"screensue"}, + "screensee": {"screensee"}, + // 鼠标键盘操控 + "computeruse": {"computeruse"}, + // 剪切板 + "clipboard": {"clipboardsee", "clipboardsue"}, + "clipboardsee": {"clipboardsee"}, + "clipboardsue": {"clipboardsue"}, + // 摄像头(抓拍/录像) + "camera": {"camerasue"}, + "camerasue": {"camerasue"}, + // 音频播放 + "speaker": {"speakeruse"}, + "speakeruse": {"speakeruse"}, +} + +// compatFullCaps 视为「全能力」的历史 caps 值:声明了这些的设备不参与能力裁剪。 +var compatFullCaps = map[string]bool{ + "cmd": true, "cmdrun": true, "deviceinfo": true, + "status": true, "cmdresult": true, +} + +// SupportsTool 判断设备是否支持某 agent 工具(基于其声明的 caps)。 +// 规则: +// - 设备未声明任何已知能力且无兼容全能力标记 → 视为全能力(旧设备兼容) +// - 声明了任一兼容全能力标记(cmd/cmdrun 等)→ 全能力 +// - 否则严格按 capabilityTools 映射匹配 +func (r *Registry) SupportsTool(deviceID, tool string) bool { + r.mu.RLock() + m, ok := r.devices[deviceID] + r.mu.RUnlock() + if !ok { + return false + } + return deviceSupportsTool(m.Caps, tool) +} + +func deviceSupportsTool(caps []string, tool string) bool { + hasKnown := false + for _, c := range caps { + if compatFullCaps[c] { + return true // 历史全能力设备 + } + if _, known := capabilityTools[c]; known { + hasKnown = true + for _, t := range capabilityTools[c] { + if t == tool { + return true + } + } + } + } + return !hasKnown // 未声明任何已知能力 → 全能力兼容 +} + func NewRegistry() *Registry { return &Registry{ devices: make(map[string]*DeviceMeta), @@ -79,6 +143,15 @@ func (r *Registry) SetStatusHandler(h func(msg map[string]interface{})) { r.onStatus = h } +// SetEventHandler 注册设备主动上报事件的回调(设备→agent 单向推送)。 +// 典型场景:摄像头识别到未知人员驻留、传感器报警等,设备无需 agent 轮询即可上报。 +// 回调参数:deviceID + 事件消息(含 type/payload 等)。 +func (r *Registry) SetEventHandler(h func(deviceID string, msg map[string]interface{})) { + r.mu.Lock() + defer r.mu.Unlock() + r.onEvent = h +} + func (r *Registry) acceptBind(token string) bool { r.mu.RLock() fn := r.acceptFn @@ -634,6 +707,24 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) { if h != nil { h(msg) } + case "event": + // 设备主动上报事件(单向推送,无需回执):摄像头发现异常、传感器报警等。 + // 转交插件层(经 SDK InjectText 异步注入 agent),无回调时仅记日志。 + id, _ := msg["device_id"].(string) + if id == "" { + id = curID + } + if id == "" { + continue + } + r.mu.RLock() + h := r.onEvent + r.mu.RUnlock() + if h != nil { + h(id, msg) + } else { + log.Printf("[remotedevice] event from %s (no handler): %v", id, msg) + } case "cmd_result": reqID, _ := msg["req_id"].(string) if reqID != "" {