Files
HomeAgent/internal/plugins/remotedevice/registry.go
JianFeeeee a7d048c922 feat(remotedevice): screensee 落盘化,回路径而非自动送模型
## 为什么

原行为把截图的 base64 直接送去视觉模型自动描述。三个问题:

1. **上下文成本**:96KB 截图 ≈ 13 万 token 的内联 base64
2. **源不可控**:生产实证 AUTO 把大图路由到不支持视觉的源,模型回
   「没有收到截图」——而我们无法区分「图没到」与「模型看不了」
3. **agent 无法决策**:不知道图存不存在,也就不知道该 describe 还是该 ocr

## 现在

截图落盘 → 回 {file,mime,size,sha256,width,height} →
agent 显式调 describe_image(path=…)/ocr_image(path=…)。
需要哪个能力由它自己判断,provider 参数也可控。

工具描述同步改写,明说「本工具不会自动描述任何内容,两步缺一不可」——
否则模型会以为拿到 file 就等于看到了画面。

## 实现

- `Registry.SaveInlineMedia(name, payload, mimeHint)`:
  解 data URL / 裸 base64 → 落盘 → 回可核对的元信息
- 文件名用**内容摘要**后缀(`<name>_<sha16>.<ext>`):
  截屏/录像会被反复抓取,用 reqID 命名会把同一张图存成多份,
  用固定名则不同内容互相覆盖 —— 摘要两个问题都避免
- `decodeInlineMedia` / `imageDimensions`:JPEG SOFn 与 PNG IHDR
  解尺寸,不解码全图;认不出的格式**省略字段而非填 0**
- 落盘失败当**工具失败**上抛,不静默退回内联:
  图已在手上却拿不到路径,agent 既没有描述也没有可查证产物
- 旧路径保留在 `auto_describe=true`(调试用)

顺带把 cmd_data_end 的媒体落盘逻辑也换成同一个方法(原来内联了
一份 MkdirAll+WriteFile 的副本)。

## 判据

新增 inline_media_test.go,六个判据经四个变异自证会红:

- 变异 A:写入截半的字节但元信息照填 → 红(只查「文件存在」会放过)
- 变异 B:尺寸写死 0 占位 → 红(元信息「看起来有」但不可信)
- 变异 C:sha256 换成假的 → 红
- 变异 D:未配置 mediaDir 时静默降级 → 红

★ 变异 C 第一次跑出「无输出」,查下来是**编译失败不是被杀**
(`sum`/`hex` 变成未使用)。这正是本项目记过的坑:
编译失败 ≠ 被杀。改写成能编译的注入后确认被杀。

## 验证

- go build ./... / go vet / go test ./internal/... 全绿
- go test -race remotedevice + multimodal 无数据竞争
- 旧判据按新契约更新时保留原意:
  TestScreenseE2E_SuccessReturnsDescription 现在同时验两条路径
  (默认不出 description 且不调 handler;auto_describe 才出)
- 部署后:39 插件加载、0 panic/fatal、plugin=multimodal
- graph.db integrity ok,blocks 2787 / relations 980 未动
- 快照 /home/newqqagent/memory/pre-screensee-persist-20261006-091536/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-06 09:16:15 +08:00

1118 lines
33 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 (
"bufio"
"bytes"
"crypto/sha1" // RFC6455 规定 Sec-WebSocket-Accept 必须用 SHA-1,勿改
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// DeviceMeta 描述一台接入了网关的设备。
// Authorized 设备自报(由客户端存储和声明),服务端仅报告不决策。
// 鉴权在设备端执行:服务端推送命令后,设备自行决定是否执行。
type DeviceMeta struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Caps []string `json:"caps"`
Info map[string]interface{} `json:"info,omitempty"`
Authorized bool `json:"authorized"`
Online bool `json:"online"`
LastSeen int64 `json:"last_seen"`
RemoteAddr string `json:"remote_addr"`
}
// wconn 表示一条活跃的 WS 连接(由网关持有)。
type wconn struct {
deviceID string
w *bufio.Writer
// wmu 序列化对该连接 bufio.Writer 的所有写。
//
// 两个角色会并发写同一连接:handleWS 主循环(读设备帧后的 hello_ack/
// bind_ack/pong 回写)与 PushJSON/PushData(agent→设备的下发路径,可能
// 来自任意 goroutine)。bufio.Writer 不是线程安全的,不加锁会在
// WriteByte/Flush 上产生 data race(生产实测触发)。
wmu sync.Mutex
}
// lockWrite 对 wconn 加写锁并返回 writer;调用方必须 defer unlockWrite。
// 单独写成方法而不是直接暴露字段,避免调用方绕过锁。
func (c *wconn) lockWrite() *bufio.Writer {
c.wmu.Lock()
return c.w
}
func (c *wconn) unlockWrite() {
c.wmu.Unlock()
}
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。
// 鉴权在设备端执行,服务端不存储授权状态。
type Registry struct {
mu sync.RWMutex
devices map[string]*DeviceMeta // deviceID -> meta(在线/历史)
conns map[string]*wconn // deviceID -> 活跃连接(支持 push)
onlineCh chan string
// onOnline/onOffline:设备上下线的同步回调(见 SetPresenceHandler)。
onOnline func(DeviceMeta)
onOffline func(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 -> 已留档结果
mediaDir string // 设备回传媒体落盘目录;空则退化为 base64 内联
}
// resultEntry 保存一次 cmdrun 的结果(供 device_ctl_cmdresult 查询)。
type resultEntry struct {
Result map[string]interface{}
Time time.Time
}
// ===== 能力矩阵:caps 声明 → 工具可用性 =====
// 设备 hello 时声明自身能力(caps),服务端据此校验工具调用:
// 摄像头只声明 camera 就不能被调 screensee/computeruse,避免无效下发。
// 兼容历史值:cmd/cmdrun 视为 shell 命令能力;未声明任何已知能力的设备
// (如旧版 GUI/waiter)视为全能力,保持向后兼容。
var capabilityTools = map[string][]string{
// 屏幕显示/查看
"screen": {"screensue", "screensee"},
"screensue": {"screensue"},
// omniparse:GUI 设备端已实现完整 case,补进能力矩阵使其可被下发
"omniparse": {"omniparse"},
"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, "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 // 未声明任何已知能力 → 全能力兼容
}
// SetMediaDir 设置设备回传媒体的落盘目录。
// 非空时 cmd_data_end 聚合完成后写入该目录,cmd_result 返回 file 路径
// (大体积 base64 内联会撑爆 LLM 上下文与工具结果管道);空则保持旧的内联行为。
func (r *Registry) SetMediaDir(dir string) {
r.mu.Lock()
r.mediaDir = dir
r.mu.Unlock()
}
// SaveInlineMedia 把一段内联媒体(data URL 或裸 base64)落盘,返回文件元信息。
//
// ★ 为什么抽出来(2026-10-06):screensee 之前只把 base64 塞给视觉模型,
// 带来两个问题——
//
// ① **上下文成本**:96KB 的截图 = 约 13 万 token 的内联 base64;
// ② **诊断不了**:模型说「没收到图片」时,服务端无法证明图到底在不在
// (只能靠日志里手写的元信息)。
//
// 落盘 + 回路径后,agent 可以**显式**调 describe_image(path=…),
// 且「图存在」这件事有可查证的落盘产物。
//
// 返回 meta(含 file/mime/size/sha256/width/height);mediaDir 为空或
// 写入失败时返回 error —— **不静默降级成内联**:调用方要据此决定是否
// 退回旧行为,而静默回退正是「失败伪装成成功」的源头。
func (r *Registry) SaveInlineMedia(name, payload, mimeHint string) (map[string]interface{}, error) {
r.mu.RLock()
dir := r.mediaDir
r.mu.RUnlock()
if dir == "" {
return nil, fmt.Errorf("未配置媒体落盘目录(SetMediaDir 未调用)")
}
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("创建媒体目录 %s 失败: %w", dir, err)
}
raw, mt, err := decodeInlineMedia(payload, mimeHint)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, fmt.Errorf("媒体内容为空(%s)", name)
}
// 文件名用内容摘要:同一张图重复截屏不堆文件,不同图不覆盖。
sum := sha256.Sum256(raw)
digest := hex.EncodeToString(sum[:])
ext := mediaExt(mt, "")
if ext == "" {
ext = ".bin"
}
fp := filepath.Join(dir, name+"_"+digest[:16]+ext)
if err := os.WriteFile(fp, raw, 0o644); err != nil {
return nil, fmt.Errorf("写媒体文件 %s 失败: %w", fp, err)
}
meta := map[string]interface{}{
"file": fp,
"mime": mt,
"size": len(raw),
"sha256": digest,
}
if w, h, ok := imageDimensions(raw); ok {
meta["width"] = w
meta["height"] = h
}
return meta, nil
}
// decodeInlineMedia 把 data URL 或裸 base64 解成字节 + mime。
func decodeInlineMedia(payload, mimeHint string) ([]byte, string, error) {
p := strings.TrimSpace(payload)
if p == "" {
return nil, "", fmt.Errorf("媒体载荷为空")
}
mime := mimeHint
if strings.HasPrefix(p, "data:") {
// data:[<mime>][;base64],<payload>
comma := strings.IndexByte(p, ',')
if comma < 0 {
return nil, "", fmt.Errorf("data URL 缺少逗号分隔符")
}
metaPart := p[len("data:"):comma]
if strings.Contains(metaPart, "base64") {
if i := strings.IndexByte(metaPart, ';'); i >= 0 {
mime = metaPart[:i]
} else {
mime = metaPart
}
} else if mime == "" {
mime = metaPart
}
p = p[comma+1:]
}
if mime == "" {
mime = "image/jpeg"
}
raw, err := base64.StdEncoding.DecodeString(p)
if err != nil {
// 有些设备会塞换行/URL-safe 变体
raw2, err2 := base64.RawStdEncoding.DecodeString(strings.TrimSpace(strings.NewReplacer("\n", "", "\r", "").Replace(p)))
if err2 != nil {
return nil, "", fmt.Errorf("base64 解码失败: %w", err)
}
raw = raw2
}
return raw, mime, nil
}
// imageDimensions 从图片头部解出像素尺寸,不解码全图。
// 不认识的格式返回 ok=false —— 调用方据此省略字段,而不是填 0。
func imageDimensions(raw []byte) (int, int, bool) {
if len(raw) < 24 {
return 0, 0, false
}
// PNG: \x89PNG\r\n\x1a\n + IHDR(width,height BE32 @16,20)
if bytes.HasPrefix(raw, []byte{0x89, 'P', 'N', 'G'}) {
return int(raw[16])<<24 | int(raw[17])<<16 | int(raw[18])<<8 | int(raw[19]),
int(raw[20])<<24 | int(raw[21])<<16 | int(raw[22])<<8 | int(raw[23]), true
}
// JPEG: 扫 SOFn 段
if bytes.HasPrefix(raw, []byte{0xFF, 0xD8}) {
for i := 2; i+9 < len(raw); {
if raw[i] != 0xFF {
i++
continue
}
marker := raw[i+1]
// SOF0..SOF3, SOF5..SOF7, SOF9..SOF11, SOF13..SOF15
if marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
h := int(raw[i+5])<<8 | int(raw[i+6])
w := int(raw[i+7])<<8 | int(raw[i+8])
if w > 0 && h > 0 {
return w, h, true
}
}
if i+3 >= len(raw) {
break
}
segLen := int(raw[i+2])<<8 | int(raw[i+3])
if segLen < 2 {
break
}
i += 2 + segLen
}
}
return 0, 0, false
}
// mediaExt 按 mime/kind 推断扩展名。
func mediaExt(mime, kind string) string {
m := strings.ToLower(mime)
switch {
case strings.Contains(m, "mp4"):
return ".mp4"
case strings.Contains(m, "webm"):
return ".webm"
case strings.Contains(m, "jpeg"), strings.Contains(m, "jpg"):
return ".jpg"
case strings.Contains(m, "png"):
return ".png"
case strings.Contains(m, "wav"):
return ".wav"
case strings.Contains(m, "mpeg"), strings.Contains(m, "mp3"):
return ".mp3"
}
k := strings.ToLower(kind)
if strings.Contains(k, "video") {
return ".mp4"
}
if strings.Contains(k, "image") || strings.Contains(k, "camera_photo") {
return ".jpg"
}
return ".bin"
}
// NewRegistry 返回初始化后的设备注册表。
func NewRegistry() *Registry {
return &Registry{
devices: make(map[string]*DeviceMeta),
conns: make(map[string]*wconn),
onlineCh: make(chan string, 16),
cmdPending: make(map[string]chan map[string]interface{}),
results: make(map[string]resultEntry),
}
}
// SetAcceptToken 设置绑定 token 校验函数(插件注入,来自 Settings)。
func (r *Registry) SetAcceptToken(fn func(token string) bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.acceptFn = fn
}
// SetStatusHandler 注册设备上报状态的回调。
func (r *Registry) SetStatusHandler(h func(msg map[string]interface{})) {
r.mu.Lock()
defer r.mu.Unlock()
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
r.mu.RUnlock()
if fn == nil {
return false
}
return fn(token)
}
// ============ 设备查询 ============
// Online 返回设备是否在线。
func (r *Registry) Online(id string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.devices[id]
return ok && m.Online
}
// Authorized 已移除:授权状态由设备端自报(DeviceMeta.Authorized),服务端不存储。
// List 返回全部设备(在线或历史)。
func (r *Registry) List() []DeviceMeta {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]DeviceMeta, 0, len(r.devices))
for _, m := range r.devices {
c := *m
out = append(out, c)
}
return out
}
// OnlineList 返回在线的设备列表。
func (r *Registry) OnlineList() []DeviceMeta {
r.mu.RLock()
defer r.mu.RUnlock()
var out []DeviceMeta
for _, m := range r.devices {
if m.Online {
c := *m
out = append(out, c)
}
}
return out
}
// Get 返回单个设备。
func (r *Registry) Get(id string) (DeviceMeta, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.devices[id]
if !ok {
return DeviceMeta{}, false
}
return *m, true
}
// ============ 授权 ============
// 已移除服务端授权存储:设备在 hello/status 中自报 authorized,
// 服务端仅透传展示;实际鉴权由设备端执行(收到 cmd 后自行决定是否执行)。
// ============ 在线状态维护 ============
func (r *Registry) register(meta DeviceMeta) {
r.mu.Lock()
meta.Online = true
meta.LastSeen = time.Now().Unix()
// 保留设备自报的授权状态(客户端鉴权,服务端不覆盖)
r.devices[meta.DeviceID] = &meta
onOnline := r.onOnline
r.mu.Unlock()
// 先回调(可能注册 device-<id> 输出通道),再发变更通知。
if onOnline != nil {
onOnline(meta)
}
r.notifyChange(meta.DeviceID)
}
func (r *Registry) markOffline(id string) {
r.mu.Lock()
if m, ok := r.devices[id]; ok {
m.Online = false
}
delete(r.conns, id)
onOffline := r.onOffline
r.mu.Unlock()
if onOffline != nil {
onOffline(id)
}
r.notifyChange(id)
}
// SetPresenceHandler 注册设备上线/下线回调。
//
// 为什么不用 ChangeChan:那是 `select { case ch <- id: default: }`,缓冲满了会**丢事件**
// (设备上下线是要跟"注册/注销输出通道"绑定的,丢一次就会留下一个死通道或漏注册)。
// 这里同步调用,且在**释放锁之后**调 —— 回调内部会回查 registry(Get/List),
// 持锁调用会自己锁死自己。
func (r *Registry) SetPresenceHandler(onOnline func(DeviceMeta), onOffline func(string)) {
r.mu.Lock()
r.onOnline = onOnline
r.onOffline = onOffline
r.mu.Unlock()
}
func (r *Registry) notifyChange(id string) {
select {
case r.onlineCh <- id:
default:
}
}
// ChangeChan 返回设备上下线变更通知。
func (r *Registry) ChangeChan() <-chan string { return r.onlineCh }
// SaveResult 保存一次命令执行结果(供 cmdresult 查询)。
func (r *Registry) SaveResult(reqID string, res map[string]interface{}) {
r.mu.Lock()
defer r.mu.Unlock()
if r.results == nil {
r.results = make(map[string]resultEntry)
}
r.results[reqID] = resultEntry{Result: res, Time: time.Now()}
}
// GetResult 返回某次命令执行的结果。
func (r *Registry) GetResult(reqID string) (map[string]interface{}, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.results[reqID]
if !ok {
return nil, false
}
return e.Result, true
}
// ============ Push(agent -> 设备) ============
// PushJSON 向在线设备推送一条 JSON 消息。
func (r *Registry) PushJSON(deviceID string, payload map[string]interface{}) error {
r.mu.RLock()
c, ok := r.conns[deviceID]
r.mu.RUnlock()
if !ok {
return fmt.Errorf("device %s not online", deviceID)
}
w := c.lockWrite()
defer c.unlockWrite()
return writeText(w, mustJSON(payload))
}
// PushCmd 向设备发送命令执行请求。
func (r *Registry) PushCmd(deviceID, reqID, command, cmdType string) error {
if cmdType == "" {
cmdType = "shell"
}
return r.PushJSON(deviceID, map[string]interface{}{
"op": "cmd",
"req_id": reqID,
"command": command,
"cmd_type": cmdType,
})
}
// PushData 向设备分块下发二进制数据(网关→设备,如 TTS 音频)。
// 协议(与 GUI 设备桥协商):
//
// 文本帧 cmd_speech_start {op, req_id, kind, mime, total} → N 个二进制帧(0x2, ≤8KB) → 文本帧 cmd_speech_end {op, req_id}
//
// kind 为语义标记(如 speech),mime 为数据 MIME 类型。设备聚合后按自身能力处理(播放等)。
func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) error {
r.mu.RLock()
c, ok := r.conns[deviceID]
r.mu.RUnlock()
if !ok {
return fmt.Errorf("device %s not online", deviceID)
}
// 整条下发(start + N 个 chunk + end)持锁:设备侧按协议串行聚合,
// 若中途被 handleWS 的 hello/pong 插帧会破坏协议顺序。
w := c.lockWrite()
defer c.unlockWrite()
if err := writeText(w, mustJSON(map[string]interface{}{
"op": "cmd_speech_start",
"req_id": reqID,
"kind": kind,
"mime": mime,
"total": len(data),
})); err != nil {
return fmt.Errorf("push data start: %w", err)
}
const chunkSize = 8192
for off := 0; off < len(data); off += chunkSize {
end := off + chunkSize
if end > len(data) {
end = len(data)
}
if err := writeBinary(w, data[off:end]); err != nil {
return fmt.Errorf("push data chunk: %w", err)
}
}
if err := writeText(w, mustJSON(map[string]interface{}{
"op": "cmd_speech_end",
"req_id": reqID,
})); err != nil {
return fmt.Errorf("push data end: %w", err)
}
return nil
}
// AwaitResult 等待某请求的结果(带超时)。快速回执会先留在 results,
// 因而 PushCmd 后才开始等待也不会丢失。
func (r *Registry) AwaitResult(reqID string, timeout time.Duration) (map[string]interface{}, error) {
ch := make(chan map[string]interface{}, 1)
r.mu.Lock()
if e, ok := r.results[reqID]; ok {
r.mu.Unlock()
return e.Result, nil
}
r.cmdPending[reqID] = ch
r.mu.Unlock()
defer func() {
r.mu.Lock()
delete(r.cmdPending, reqID)
r.mu.Unlock()
}()
select {
case res := <-ch:
return res, nil
case <-time.After(timeout):
return nil, fmt.Errorf("timeout waiting for device result")
}
}
// deliverResult 先留档再通知等待者,消除设备极速回执早于 AwaitResult 的竞态。
func (r *Registry) deliverResult(reqID string, res map[string]interface{}) {
r.mu.Lock()
if r.results == nil {
r.results = make(map[string]resultEntry)
}
r.results[reqID] = resultEntry{Result: res, Time: time.Now()}
ch, ok := r.cmdPending[reqID]
r.mu.Unlock()
if ok {
select {
case ch <- res:
default:
}
}
}
// ============ WS 网关(标准库 Hijacker,RFC6455 子集) ============
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
// wsAccept 计算 RFC6455 §4.2.2 握手响应值:base64(SHA1(key + GUID))。
// 注意必须用 SHA-1——这是协议规定而非安全选择;此前误用 SHA-256 导致
// 所有标准 WS 客户端(浏览器/Electron/各语言标准库)校验 Accept 失败
// 后立即断开,设备永远无法完成 hello 注册(devicedetect 恒为空)。
func wsAccept(key string) string {
h := sha1.Sum([]byte(key + wsGUID))
return base64.StdEncoding.EncodeToString(h[:])
}
func headerListHas(h, sub string) bool {
for _, part := range strings.Split(h, ",") {
if strings.EqualFold(strings.TrimSpace(part), sub) {
return true
}
}
return false
}
func httpUpgrade(w http.ResponseWriter, r *http.Request) (net.Conn, *bufio.ReadWriter, error) {
if !headerListHas(r.Header.Get("Upgrade"), "websocket") ||
!headerListHas(r.Header.Get("Connection"), "upgrade") {
return nil, nil, fmt.Errorf("not a websocket upgrade request")
}
key := r.Header.Get("Sec-WebSocket-Key")
if key == "" {
return nil, nil, fmt.Errorf("missing Sec-WebSocket-Key")
}
hj, ok := w.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("hijack not supported")
}
conn, rw, err := hj.Hijack()
if err != nil {
return nil, nil, err
}
accept := wsAccept(key)
resp := "HTTP/1.1 101 Switching Protocols" + "\x0d\x0a" +
"Upgrade: websocket\x0d\x0aConnection: Upgrade\x0d\x0a" +
"Sec-WebSocket-Accept: " + accept + "\x0d\x0a\x0d\x0a"
if _, err := rw.WriteString(resp); err != nil {
conn.Close()
return nil, nil, err
}
if err := rw.Flush(); err != nil {
conn.Close()
return nil, nil, err
}
return conn, rw, nil
}
// readFrame 读取一个 WS 帧。返回 (payload, isClose, err)。
// opcode: 0x1 文本 / 0x2 二进制(设备→网关大体积数据分块,如录像回传)。
func readFrame(r *bufio.Reader) ([]byte, bool, byte, error) {
b0, err := r.ReadByte()
if err != nil {
return nil, true, 0, err
}
opcode := b0 & 0x0f
b1, err := r.ReadByte()
if err != nil {
return nil, true, opcode, err
}
masked := b1&0x80 != 0
length := uint64(b1 & 0x7f)
if length == 126 {
var ext [2]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return nil, true, opcode, err
}
length = uint64(binary.BigEndian.Uint16(ext[:]))
} else if length == 127 {
var ext [8]byte
if _, err := io.ReadFull(r, ext[:]); err != nil {
return nil, true, opcode, err
}
length = binary.BigEndian.Uint64(ext[:])
}
// 二进制帧允许更大(录像分块聚合,单帧仍限 8MB 防滥用)
maxFrame := uint64(1 << 20)
if opcode == 0x2 {
maxFrame = 8 << 20
}
if length > maxFrame {
return nil, true, opcode, fmt.Errorf("frame too large")
}
var maskKey [4]byte
if masked {
if _, err := io.ReadFull(r, maskKey[:]); err != nil {
return nil, true, opcode, err
}
}
payload := make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, true, opcode, err
}
if masked {
for i := range payload {
payload[i] ^= maskKey[i%4]
}
}
switch opcode {
case 0x1, 0x2:
return payload, false, opcode, nil
case 0x8:
return nil, true, opcode, nil
case 0xa:
return nil, false, opcode, nil
case 0x9:
return nil, false, opcode, errPing
default:
return nil, false, opcode, fmt.Errorf("unsupported opcode %x", opcode)
}
}
var errPing = fmt.Errorf("ping")
func writeText(w *bufio.Writer, payload []byte) error {
return writeFrame(w, 0x1, payload)
}
// writeBinary 发送 WS 二进制帧(0x2):网关→设备大体积数据(如 TTS 音频)分块下发。
func writeBinary(w *bufio.Writer, payload []byte) error {
return writeFrame(w, 0x2, payload)
}
func writeFrame(w *bufio.Writer, opcode byte, payload []byte) error {
if err := writeFrameHeader(w, opcode, len(payload)); err != nil {
return err
}
if _, err := w.Write(payload); err != nil {
return err
}
return w.Flush()
}
func writePong(w *bufio.Writer) error {
// 必须走 writeFrame(它 Flush)。
//
// 回归的 bug:这里原先是裸的 writeFrameHeader,**不 Flush**。设备空闲时
// 没有任何别的写会顺带把 bufio 缓冲刷出去,于是 pong 永远留在服务端缓冲里,
// 客户端等 2 倍 ping 间隔(默认 30s×2 = 60s)读超时断开、重连——
// 实测表现就是「设备通道每 60 秒掉线一次」,连带着 outputch 反复注销/注册。
return writeFrame(w, 0xa, nil)
}
func writeFrameHeader(w *bufio.Writer, opcode byte, length int) error {
if err := w.WriteByte(0x80 | opcode); err != nil {
return err
}
if length < 126 {
if err := w.WriteByte(byte(length)); err != nil {
return err
}
} else if length <= 0xffff {
if err := w.WriteByte(126); err != nil {
return err
}
var ext [2]byte
binary.BigEndian.PutUint16(ext[:], uint16(length))
if _, err := w.Write(ext[:]); err != nil {
return err
}
} else {
if err := w.WriteByte(127); err != nil {
return err
}
var ext [8]byte
binary.BigEndian.PutUint64(ext[:], uint64(length))
if _, err := w.Write(ext[:]); err != nil {
return err
}
}
return nil
}
func mustJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte("{}")
}
return b
}
// ServeWS 是 WS 端点的 HTTP handler:认证 token(query 或 Sec-WebSocket-Protocol),
// 升级后进入 handleWS。未带 token 也允许 hello(登记设备),bind 时校验。
func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
token := req.URL.Query().Get("token")
if token == "" {
token = strings.TrimSpace(req.Header.Get("X-API-Key"))
}
if token == "" {
for _, p := range req.Header.Values("Sec-WebSocket-Protocol") {
if strings.HasPrefix(p, "homeagent.") {
token = strings.TrimPrefix(p, "homeagent.")
break
}
}
}
handshakeAuthorized := token != "" && r.acceptBind(token)
if token != "" && !handshakeAuthorized {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
conn, rw, err := httpUpgrade(w, req)
if err != nil {
http.Error(w, "upgrade failed: "+err.Error(), http.StatusBadRequest)
return
}
log.Printf("[remotedevice] ws connected from %s", conn.RemoteAddr())
go r.handleWS(conn, rw, handshakeAuthorized)
}
// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。
//
// handleWS 主循环与 Push* 是两条并发写同一 bufio.Writer 的路径,
// 必须共用同一把锁。handleWS 里拿到的是 rw.Writer(与 conns 存储的是
// 同一个对象),回写前必须经此函数取锁,否则跟 Push* 依然会撞。
//
// 注意设备已离线(conns 中已删除)时直接报错——设备断开后仍尝试
// 回写没有意义,还可能在已关闭的 bufio 上写入。
func (r *Registry) wsWriteLocked(deviceID string, fn func(w *bufio.Writer) error) error {
r.mu.RLock()
c, ok := r.conns[deviceID]
r.mu.RUnlock()
if !ok {
return fmt.Errorf("device %s not online", deviceID)
}
w := c.lockWrite()
defer c.unlockWrite()
return fn(w)
}
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter, handshakeAuthorized bool) {
defer conn.Close()
var curID string
var pendingMeta *DeviceMeta
var bound bool
defer func() {
if curID != "" {
if bound {
r.markOffline(curID)
} else {
r.mu.Lock()
delete(r.conns, curID)
r.mu.Unlock()
}
}
}()
// 二进制分块聚合状态(设备→网关,如录像回传):
// cmd_data_start 开启 → 0x2 帧追加 → cmd_data_end 聚合存入 cmdresult
var dataAccum *dataAccumulator
for {
payload, isClose, opcode, err := readFrame(rw.Reader)
if err != nil {
if err == errPing {
// ★ 回 pong 绝不能因为「还没 bind」而失败。
//
// 原实现无条件走 wsWriteLocked(curID, writePong),而 conns 表
// **只在 bind 成功后才写入**(bind 之前刻意不把连接暴露给查询/
// 命令路径)。于是握手完成、bind 尚未到达时来的 ping 找不到写
// 入口 → 返回错误 → 读循环 return → **连接被关掉**。
//
// 生产后果(日志实证):客户端每 30s 一次 ping,只要有一次落在
// 未 bind 窗口就断连,表现为同一设备 20 秒内多次 ws connected、
// online/offline 反复交替,输出通道跟着反复注销/注册。
//
// 正确做法:pong 直接写本连接的 writer。此时该连接**尚未**进入
// conns(即没有 Push* 会碰它的 writer),不存在并发写风险;
// 已 bind 时才需要取写锁(Push* 可能正在写同一 buffer)。
var werr error
if bound {
werr = r.wsWriteLocked(curID, writePong)
} else {
werr = writePong(rw.Writer)
}
if werr != nil {
return
}
continue
}
return
}
if isClose {
return
}
if opcode == 0x2 {
// 二进制帧:处于聚合状态时追加数据块,否则忽略
if dataAccum != nil {
dataAccum.chunks = append(dataAccum.chunks, payload)
dataAccum.got += len(payload)
// 防滥用:超出声明 total 的 2 倍或硬上限 64MB 时放弃聚合
limit := int64(64 << 20)
if dataAccum.total > 0 {
declaredLimit := int64(dataAccum.total)*2 + 1024
if declaredLimit < limit {
limit = declaredLimit
}
}
if int64(dataAccum.got) > limit {
log.Printf("[remotedevice] data accumulation exceeded limit for req %s, dropped", dataAccum.reqID)
dataAccum = nil
}
}
continue
}
var msg map[string]interface{}
if err := json.Unmarshal(payload, &msg); err != nil {
continue
}
op, _ := msg["op"].(string)
if !bound && op != "hello" && op != "bind" {
continue
}
switch op {
case "hello":
meta := metaFromMsg(msg)
if meta.DeviceID == "" {
continue
}
meta.RemoteAddr = conn.RemoteAddr().String()
pendingMeta = &meta
curID = meta.DeviceID
// Bind 前不把连接暴露给查询或命令下发路径;此时只有当前读循环会写。
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
"op": "hello_ack",
"device": meta.DeviceID,
"online": false,
})); err != nil {
return
}
case "bind":
token, _ := msg["token"].(string)
id, _ := msg["device_id"].(string)
bindAuthorized := handshakeAuthorized || r.acceptBind(token)
if pendingMeta == nil || id == "" || id != pendingMeta.DeviceID || !bindAuthorized {
_ = writeText(rw.Writer, mustJSON(map[string]interface{}{
"op": "bind_ack", "ok": false, "error": "bind rejected",
}))
return
}
r.mu.Lock()
r.conns[id] = &wconn{deviceID: id, w: rw.Writer}
r.mu.Unlock()
bound = true
r.register(*pendingMeta)
if err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
}); err != nil {
return
}
case "status":
id, _ := msg["device_id"].(string)
r.mu.Lock()
if m, ok := r.devices[id]; ok {
m.LastSeen = time.Now().Unix()
}
r.mu.Unlock()
r.mu.RLock()
h := r.onStatus
r.mu.RUnlock()
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 != "" {
r.deliverResult(reqID, msg)
}
case "cmd_data_start":
reqID, _ := msg["req_id"].(string)
if reqID == "" {
continue
}
total, _ := msg["total"].(float64)
kind, _ := msg["kind"].(string)
mime, _ := msg["mime"].(string)
dataAccum = &dataAccumulator{
reqID: reqID,
kind: kind,
mime: mime,
total: int(total),
}
case "cmd_data_end":
reqID, _ := msg["req_id"].(string)
status, _ := msg["status"].(string)
if dataAccum == nil || dataAccum.reqID != reqID {
continue
}
acc := dataAccum
dataAccum = nil
if status != "ok" {
r.SaveResult(reqID, map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "status": "error",
"error": "device reported transfer failure",
})
r.deliverResult(reqID, map[string]interface{}{
"op": "cmd_result", "req_id": reqID, "status": "error",
"error": "device reported transfer failure",
})
continue
}
data := make([]byte, 0, acc.got)
for _, c := range acc.chunks {
data = append(data, c...)
}
res := map[string]interface{}{
"op": "cmd_result",
"req_id": reqID,
"status": "ok",
"kind": acc.kind,
"mime": acc.mime,
"size": len(data),
"expected": acc.total,
}
// 媒体落盘模式:写入 <mediaDir>/<reqID>.<ext>,cmd_result 返回 file 路径。
// 大体积 base64 内联会撑爆 LLM 上下文(一段 10s 录像即数 MB),
// agent 应拿路径后用 files/describe_image/ocr 等工具消费。
if meta, err := r.SaveInlineMedia(reqID, base64.StdEncoding.EncodeToString(data), acc.mime); err == nil {
for k, v := range meta {
res[k] = v
}
} else {
log.Printf("[remotedevice] media save %s: %v", reqID, err)
}
// 未落盘时保持旧行为:base64 内联返回(小体积数据仍可用)。
// ★ 这里仍内联是可接受的退化——上面的 log 已记录原因。
// screensee 那边不同:它把落盘失败当**工具失败**上抛,
// 因为「拿到了图却没落盘」会让 agent 拿不到路径也无从排查。
if _, hasFile := res["file"]; !hasFile {
res["data_base64"] = base64.StdEncoding.EncodeToString(data)
}
r.SaveResult(reqID, res)
r.deliverResult(reqID, res)
}
}
}
// dataAccumulator 聚合设备→网关的二进制分块传输(如录像回传)。
type dataAccumulator struct {
reqID string
kind string
mime string
total int
chunks [][]byte
got int
}
func metaFromMsg(msg map[string]interface{}) DeviceMeta {
var meta DeviceMeta
if d, ok := msg["device"].(map[string]interface{}); ok {
if v, ok := d["device_id"].(string); ok {
meta.DeviceID = v
}
if v, ok := d["name"].(string); ok {
meta.Name = v
}
if v, ok := d["kind"].(string); ok {
meta.Kind = v
}
if caps, ok := d["caps"].([]interface{}); ok {
for _, c := range caps {
if s, ok := c.(string); ok {
meta.Caps = append(meta.Caps, s)
}
}
}
if v, ok := d["authorized"].(bool); ok {
meta.Authorized = v
}
if info, ok := d["info"].(map[string]interface{}); ok {
if len(info) > 0 {
meta.Info = info
}
}
}
return meta
}