mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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 {
|
if !m.Online {
|
||||||
return nil, fmt.Errorf("device %s 不在线", id)
|
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()
|
reqID := newReqID()
|
||||||
if err := d.reg.PushCmd(id, reqID, "screensee", "homeagent"); err != nil {
|
if err := d.reg.PushCmd(id, reqID, "screensee", "homeagent"); err != nil {
|
||||||
return nil, fmt.Errorf("下发截屏命令失败: %w", err)
|
return nil, fmt.Errorf("下发截屏命令失败: %w", err)
|
||||||
@ -439,6 +442,9 @@ func (d *devicectlDevice) computeruse(args map[string]interface{}) (interface{},
|
|||||||
if !m.Online {
|
if !m.Online {
|
||||||
return nil, fmt.Errorf("device %s 不在线", id)
|
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 所选屏)
|
// 构造 GUI 端约定的 JSON 参数(坐标相对 screensueDisplay 所选屏)
|
||||||
params := map[string]interface{}{"action": action}
|
params := map[string]interface{}{"action": action}
|
||||||
@ -492,8 +498,9 @@ func (d *devicectlDevice) computeruse(args map[string]interface{}) (interface{},
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// clipboardCheck 检查设备可操作性(存在/已授权/在线),返回错误或 nil。
|
// clipboardCheck 检查设备可操作性(存在/已授权/在线/能力声明),返回错误或 nil。
|
||||||
func (d *devicectlDevice) clipboardCheck(id, verb string) error {
|
// tool 为空时跳过能力校验(如 device_ctl_cmdrun 由自身逻辑处理)。
|
||||||
|
func (d *devicectlDevice) clipboardCheck(id, verb, tool string) error {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return fmt.Errorf("device_id required")
|
return fmt.Errorf("device_id required")
|
||||||
}
|
}
|
||||||
@ -507,6 +514,9 @@ func (d *devicectlDevice) clipboardCheck(id, verb string) error {
|
|||||||
if !m.Online {
|
if !m.Online {
|
||||||
return fmt.Errorf("device %s 不在线", id)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -514,7 +524,7 @@ func (d *devicectlDevice) clipboardCheck(id, verb string) error {
|
|||||||
// 协议(GUI 配套):homeagent-clipboardsee → 回执 output 字段为剪切板文字。
|
// 协议(GUI 配套):homeagent-clipboardsee → 回执 output 字段为剪切板文字。
|
||||||
func (d *devicectlDevice) clipboardsee(args map[string]interface{}) (interface{}, error) {
|
func (d *devicectlDevice) clipboardsee(args map[string]interface{}) (interface{}, error) {
|
||||||
id, _ := args["device_id"].(string)
|
id, _ := args["device_id"].(string)
|
||||||
if err := d.clipboardCheck(id, "读取"); err != nil {
|
if err := d.clipboardCheck(id, "读取", "clipboardsee"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reqID := newReqID()
|
reqID := newReqID()
|
||||||
@ -551,7 +561,7 @@ func (d *devicectlDevice) clipboardsue(args map[string]interface{}) (interface{}
|
|||||||
if text == "" {
|
if text == "" {
|
||||||
return nil, fmt.Errorf("text required(要写入剪切板的内容)")
|
return nil, fmt.Errorf("text required(要写入剪切板的内容)")
|
||||||
}
|
}
|
||||||
if err := d.clipboardCheck(id, "写入"); err != nil {
|
if err := d.clipboardCheck(id, "写入", "clipboardsue"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reqID := newReqID()
|
reqID := newReqID()
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
"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)
|
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 设备通道 ----------------
|
// ---- REST 管理面 + WS 设备通道 ----------------
|
||||||
p.registerRoutes()
|
p.registerRoutes()
|
||||||
p.server = &http.Server{Addr: p.addr, Handler: p.mux}
|
p.server = &http.Server{Addr: p.addr, Handler: p.mux}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ type Registry struct {
|
|||||||
conns map[string]*wconn // deviceID -> 活跃连接(支持 push)
|
conns map[string]*wconn // deviceID -> 活跃连接(支持 push)
|
||||||
onlineCh chan string
|
onlineCh chan string
|
||||||
onStatus func(msg map[string]interface{})
|
onStatus func(msg map[string]interface{})
|
||||||
|
onEvent func(deviceID string, msg map[string]interface{})
|
||||||
acceptFn func(token string) bool
|
acceptFn func(token string) bool
|
||||||
cmdPending map[string]chan map[string]interface{} // reqID -> 结果 channel
|
cmdPending map[string]chan map[string]interface{} // reqID -> 结果 channel
|
||||||
results map[string]resultEntry // reqID -> 已留档结果
|
results map[string]resultEntry // reqID -> 已留档结果
|
||||||
@ -54,6 +55,69 @@ type resultEntry struct {
|
|||||||
Time time.Time
|
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 {
|
func NewRegistry() *Registry {
|
||||||
return &Registry{
|
return &Registry{
|
||||||
devices: make(map[string]*DeviceMeta),
|
devices: make(map[string]*DeviceMeta),
|
||||||
@ -79,6 +143,15 @@ func (r *Registry) SetStatusHandler(h func(msg map[string]interface{})) {
|
|||||||
r.onStatus = h
|
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 {
|
func (r *Registry) acceptBind(token string) bool {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
fn := r.acceptFn
|
fn := r.acceptFn
|
||||||
@ -634,6 +707,24 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
|
|||||||
if h != nil {
|
if h != nil {
|
||||||
h(msg)
|
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":
|
case "cmd_result":
|
||||||
reqID, _ := msg["req_id"].(string)
|
reqID, _ := msg["req_id"].(string)
|
||||||
if reqID != "" {
|
if reqID != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user