feat: 设备能力矩阵校验 + 设备主动上报事件通道

回应架构讨论: 外接设备各自声明能力, 且补齐设备→agent 单向推送缺口。

1. 能力矩阵 (registry.go):
   - capabilityTools 映射: caps 声明 → 可用工具
     screen/screensue/screensee, computeruse, clipboard(see/sue),
     camera/camerasue, speaker/speakeruse
   - SupportsTool 校验: screensee/computeruse/clipboard* 执行前检查
     目标设备是否声明对应能力, 未声明直接报错(不再下发到设备端才失败)
   - 兼容规则: 声明 cmd/cmdrun 等历史值 → 全能力;
     未声明任何已知能力 → 全能力(旧设备兼容);
     有已知能力声明则严格匹配

2. 设备主动上报事件 (WS op=event):
   - 设备可推 {op:event, type, detail/payload} 无需回执
   - 插件层 SetEventHandler 回调: 格式化为人类可读文本,
     经 SDK InjectInput 异步注入 agent(source=device/{id}, 回复路由回同通道)
   - 同设备同类型事件 10s 节流防传感器风暴
   - 场景: 摄像头识别未知人员驻留主动告警, agent 收到后自主处置

测试: 能力矩阵8场景 + 摄像头调screensee被拒 + 事件上报回调(含device_id回退)
This commit is contained in:
JianFeeeee
2026-08-21 19:58:37 +08:00
parent b6c1ef15d2
commit 4dcd3623cb
4 changed files with 262 additions and 4 deletions

View File

@ -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])
}
}

View File

@ -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()

View File

@ -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 异步注入 agentsource=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}

View File

@ -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 != "" {