SSE、WebSocket 与 mDNS
20.1 GET /v1/api/systems/stream
认证遵循 2.3;启用 Bearer 时必须携带请求头,免 token 模式可直接连接。响应头:
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
| event 名 | 首次推送 | 后续周期 | data |
|---|---|---|---|
| health | 连接后立即尝试 | 5 s | status、uptime_seconds、timestamp |
| hub | 连接后立即尝试 | 5 s | 同主机资源查询 |
| robot | 连接后立即尝试 | 10 s | 同机器人状态查询 |
单次采集失败会跳过该次事件,不保证恰好每个周期都有数据。health SSE 直接读取基础健康服务,不附加 /systems/health handler 添加的 provisioning 对象。
event: health
data: {"status":"ok","uptime_seconds":3600,"timestamp":"2026-11-13T08:00:00Z"}
event: robot
data: {"robot_type":"adapter-name","connected":true,"battery":85}
没有 REST 信封。当前不发送 event id,也不实现基于 Last-Event-ID 的历史补发;断开后重新连接得到新快照。
curl -N -sS -H "Authorization: Bearer $ORSUS_TOKEN" "$ORSUS_ORIGIN/v1/api/systems/stream"
启用 Bearer 的设备使用支持自定义头的 SSE 客户端。原生 EventSource 不能设置 Authorization,不能把 Bearer token 放进 ?token= 来期待此接口认证通过;免 token 设备可直接使用原生 EventSource。
20.2 GET /v1/api/terminal/ws
URL:ws://<IP>:8898/v1/api/terminal/ws?token=<终端令牌>,HTTPS 网关后改用 wss。
校验的是传入终端 handler 的有效 token。普通启动通常来自 [terminal].token,有效值非空则校验 query token,失败为 HTTP 401/1006;普通 prod 启动还会检查终端 token 配置。标准包接管及制造放行兼容路径设置 FactoryTokenBypass=true,路由装配会把有效终端 token 清空,即使磁盘配置原来有 token,也不在此入口校验。它不是 API auth.tokens 中的 Bearer 令牌,也不是 Linux/SSH 密码。终端会话具有实际 shell 权限。
所有应用消息用 WebSocket Binary 帧,首字节定义:
| 类型 | 首字节 | 方向 | 后续载荷 |
|---|---|---|---|
| Input | 0x00 | 客户端 → 服务端 | 原始输入字节 |
| Resize | 0x01 | 客户端 → 服务端 | 恰好 4 字节:cols uint16 大端,rows uint16 大端 |
| Output | 0x02 | 服务端 → 客户端 | PTY 输出字节 |
| Error | 0x03 | 服务端 → 客户端 | UTF-8 错误文本,随后连接可能关闭 |
Text 帧会被忽略。Resize 总消息长 5 字节,非法尺寸帧导致错误并结束会话。默认空闲超时 10 分钟,Input/Resize 重置计时;仅有终端输出不重置。会话数受 max_sessions 约束,配置示例为 3;达到上限可能在 WebSocket 升级后返回 Error 帧。
UTF-8 正确编码示例(按编码后的字节数分配缓冲区):
function attachTerminal(origin, terminalToken, onOutput, onError) {
const url = new URL('/v1/api/terminal/ws', origin);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
if (terminalToken) url.searchParams.set('token', terminalToken);
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
const encoder = new TextEncoder();
const outputDecoder = new TextDecoder();
ws.onmessage = ({ data }) => {
const bytes = new Uint8Array(data);
if (!bytes.length) return;
if (bytes[0] === 0x02) {
onOutput(outputDecoder.decode(bytes.subarray(1), { stream: true }));
} else if (bytes[0] === 0x03) {
onError(new TextDecoder().decode(bytes.subarray(1)));
}
};
const send = frame => {
if (ws.readyState !== WebSocket.OPEN) throw new Error('终端尚未连接');
ws.send(frame);
};
return {
ws,
input(text) {
const payload = encoder.encode(text);
const frame = new Uint8Array(payload.length + 1);
frame[0] = 0x00;
frame.set(payload, 1);
send(frame);
},
resize(cols, rows) {
if (![cols, rows].every(n => Number.isInteger(n) && n > 0 && n <= 65535)) {
throw new Error('窗口尺寸必须为 1–65535 的整数');
}
const frame = new ArrayBuffer(5);
const view = new DataView(frame);
view.setUint8(0, 0x01);
view.setUint16(1, cols, false);
view.setUint16(3, rows, false);
send(frame);
},
close() { ws.close(); }
};
}
客户端收到 open 后再发首个 Resize;关闭页面时关闭连接。会话断开不提供 shell 会话续接保证。
20.3 mDNS 发现
- 服务类型:
_gshub._tcp;域:local.。 - 实例名:
GSHub-<device_id>;TXT 当前为proto=1。 - 端口来自正在运行的 HTTP 服务配置,不应硬编码历史端口。
- 通过活动 IPv4、多播网络接口发布,约每 30 秒刷新以适应接口变化。
- 发现是尽力而为,多播不通不表示 HTTP 不可用;可直接使用部署提供的 IP。
发现后仍调用 systems/device 校验 SN,再携带正确凭据调用业务接口。