mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
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:
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user