Files
HomeAgent/internal/plugins/remotedevice/plugin.go
JianFeeeee 4dcd3623cb 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回退)
2026-08-21 19:58:37 +08:00

342 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package remotedevice
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func init() {
plugin.RegisterPluginMeta("remotedevice", "远程设备网关", "Remote Device")
plugin.RegisterFactory("remotedevice", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
const defaultAddr = "127.0.0.1:9890"
// Plugin 是 remotedevice 设备接入网关插件:
// 持有 HTTP 服务WS 设备通道 + REST 管理面)与 devicectl Deviceagent 工具)。
type Plugin struct {
name string
registry *Registry
mux *http.ServeMux
server *http.Server
addr string
token string
sdk *sdk.PluginSDK
dev *devicectlDevice
}
func New(name string) *Plugin {
return &Plugin{
name: name,
registry: NewRegistry(),
mux: http.NewServeMux(),
}
}
func (p *Plugin) Name() string { return p.name }
func genToken() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("tok-%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func newReqID() string {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("req-%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
// ---- 设置 ----------------
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen_addr", Default: defaultAddr, Type: "string", DisplayName: "监听地址", Description: "设备网关 HTTP/WS 监听地址(默认 127.0.0.1:9890仅本机", Category: "remotedevice"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "ws_token", Default: "", Type: "password", DisplayName: "接入 Token", Description: "设备绑定/接入时使用的令牌;留空启动时自动生成", Category: "remotedevice"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "authorized_devices", Default: "", Type: "text", DisplayName: "已授权设备", Description: "逗号分隔的已授权设备 ID 列表(由系统维护)", Category: "remotedevice"})
p.addr = defaultAddr
if v, _ := s.Settings().Get("listen_addr"); v != nil {
if s2, ok := v.(string); ok && s2 != "" {
p.addr = s2
}
}
p.token = ""
if v, _ := s.Settings().Get("ws_token"); v != nil {
if s2, ok := v.(string); ok && s2 != "" {
p.token = s2
}
}
if p.token == "" {
p.token = genToken()
if err := s.Settings().Set("ws_token", p.token); err != nil {
log.Printf("[remotedevice] persist ws_token: %v", err)
}
}
p.registry.SetAcceptToken(func(provided string) bool {
return provided != "" && provided == p.token
})
// 恢复已授权设备集合
if v, _ := s.Settings().Get("authorized_devices"); v != nil {
if s2, ok := v.(string); ok && s2 != "" {
var ids []string
for _, id := range strings.Split(s2, ",") {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
p.registry.RestoreAuthorized(ids)
}
}
// ---- devicectl Deviceagent 工具) ----------------
p.dev = &devicectlDevice{reg: p.registry, persist: p.persistAuthorized}
// screensee 视觉描述回调:截屏回传后用视觉模型描述屏幕内容
p.dev.SetSeeHandler(p.describeScreen)
if err := s.RegisterChannel("devicectl", p.dev); err != nil {
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}
go func() {
log.Printf("[remotedevice] device gateway listening on %s", p.addr)
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[remotedevice] server error: %v", err)
}
}()
return nil
}
// persistAuthorized 在授权变更后写回设置(持久化重启不丢)。
func (p *Plugin) persistAuthorized() {
if p.sdk == nil {
return
}
ids := p.registry.AuthorizedIDs()
_ = p.sdk.Settings().Set("authorized_devices", strings.Join(ids, ","))
}
func (p *Plugin) registerRoutes() {
// 设备通道WS
p.mux.HandleFunc("/api/v1/device/ws", p.registry.ServeWS)
// REST 管理面(全部需 token
p.mux.HandleFunc("/api/v1/device", p.requireToken(p.handleDeviceList))
p.mux.HandleFunc("/api/v1/device/online", p.requireToken(p.handleDeviceOnline))
p.mux.HandleFunc("/api/v1/device/", p.requireToken(p.handleDeviceByID))
p.mux.HandleFunc("/api/v1/device/push", p.requireToken(p.handleDevicePush))
p.mux.HandleFunc("/api/v1/device/auth", p.requireToken(p.handleDeviceAuth))
}
// requireToken 校验 REST 请求的接入令牌X-API-Key header 或 ?token=)。
func (p *Plugin) requireToken(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
k := r.Header.Get("X-API-Key")
if k == "" {
k = r.URL.Query().Get("token")
}
if k == "" || k != p.token {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (p *Plugin) handleDeviceList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": p.registry.List()})
}
func (p *Plugin) handleDeviceOnline(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": p.registry.OnlineList()})
}
func (p *Plugin) handleDeviceByID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/v1/device/")
if id == "" || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
if r.Method == http.MethodGet {
m, ok := p.registry.Get(id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"})
return
}
writeJSON(w, http.StatusOK, m)
return
}
http.NotFound(w, r)
}
func (p *Plugin) handleDevicePush(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var req struct {
DeviceID string `json:"device_id"`
Payload map[string]interface{} `json:"payload"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"})
return
}
if err := p.registry.PushJSON(req.DeviceID, req.Payload); err != nil {
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok"})
}
func (p *Plugin) handleDeviceAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var req struct {
DeviceID string `json:"device_id"`
Authorize bool `json:"authorize"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
return
}
if req.DeviceID == "" {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"})
return
}
if _, ok := p.registry.Get(req.DeviceID); !ok {
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": "device not found"})
return
}
p.registry.SetAuthorized(req.DeviceID, req.Authorize)
p.persistAuthorized()
writeJSON(w, http.StatusOK, map[string]interface{}{"device_id": req.DeviceID, "authorized": req.Authorize})
}
// describeScreen 用视觉模型描述设备屏幕截图screensee 回调)。
// provider 为空时使用默认 LLM 源;模型不支持视觉时返回友好错误。
func (p *Plugin) describeScreen(dataURL string, provider string) string {
if p.sdk == nil || p.sdk.LLM() == nil {
return "LLM 不可用,无法描述屏幕内容"
}
llm := p.sdk.LLM()
req := &sdk.LLMCompletionRequest{
MaxTokens: 2048,
Messages: []sdk.LLMMessage{{
Role: "user",
Blocks: []sdk.LLMContentBlock{
{Type: "text", Text: "这是用户设备的屏幕截图。请详细描述屏幕上显示的内容:正在运行的窗口/应用、可见的文字内容、界面状态等。如果是代码编辑器或终端,尽量转述关键文字信息。"},
{Type: "image_url", ImageURL: dataURL},
},
}},
}
// 指定源:临时切换(低频操作,用完恢复原源)
if provider != "" {
prev := llm.CurrentSource()
if err := llm.SetSource(provider); err != nil {
log.Printf("[remotedevice] screensee set source %s: %v", provider, err)
} else if prev != "" {
defer func() { _ = llm.SetSource(prev) }()
}
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
resp, err := llm.Chat(ctx, req)
if err != nil {
return fmt.Sprintf("屏幕截图视觉描述失败: %v当前模型可能不支持图像输入", err)
}
return resp.Content
}
func (p *Plugin) Stop() error {
if p.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return p.server.Shutdown(ctx)
}
return nil
}