240 lines
6.3 KiB
JavaScript
240 lines
6.3 KiB
JavaScript
/**
|
|
* 远程播放桥接:SSE 订阅 + 命令队列 + 本地状态回传。
|
|
*/
|
|
import { API_VERSION } from "./api.js";
|
|
|
|
const API_ROOT = "/api/agv";
|
|
const REMOTE_METHODS = new Set(["playAction", "stopAction", "clearActions"]);
|
|
const CLIENT_ID = globalThis.crypto?.randomUUID?.() || `client-${Date.now()}`;
|
|
|
|
export function connectRemotePlayback(playbackApi, { onStatus } = {}) {
|
|
let lastRevision = 0;
|
|
let connected = false;
|
|
let es = null;
|
|
const queue = [];
|
|
let draining = false;
|
|
let applyingRemote = 0;
|
|
|
|
function setStatus(text, kind = "idle") {
|
|
onStatus?.({ text, kind, connected, revision: lastRevision });
|
|
}
|
|
|
|
function shouldSkipEcho(msg) {
|
|
return msg?.sourceClientId && msg.sourceClientId === CLIENT_ID;
|
|
}
|
|
|
|
async function waitUntilReady(maxMs = 20000) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < maxMs) {
|
|
if (playbackApi.getState?.().ready) return true;
|
|
await new Promise((r) => setTimeout(r, 150));
|
|
}
|
|
return playbackApi.getState?.().ready;
|
|
}
|
|
|
|
async function withRemoteApply(fn) {
|
|
applyingRemote += 1;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
applyingRemote -= 1;
|
|
}
|
|
}
|
|
|
|
async function ensureModel(modelId) {
|
|
if (!modelId) return true;
|
|
const cur = playbackApi.getState?.() || {};
|
|
if (cur.modelId === modelId && cur.ready) return true;
|
|
const res = await playbackApi.request({
|
|
version: API_VERSION,
|
|
method: "clearActions",
|
|
params: { modelId },
|
|
});
|
|
if (!res.ok) {
|
|
setStatus(`远程加载失败: ${res.error?.message || "unknown"}`, "error");
|
|
return false;
|
|
}
|
|
return waitUntilReady();
|
|
}
|
|
|
|
async function applySnapshot(state) {
|
|
if (!state) return false;
|
|
const revision = Number(state.revision) || 0;
|
|
if (revision <= lastRevision) return true;
|
|
|
|
return withRemoteApply(async () => {
|
|
const modelId = state.modelId;
|
|
const desired = state.activeActions || [];
|
|
|
|
if (!(await ensureModel(modelId))) return false;
|
|
|
|
const cur = playbackApi.getState?.() || {};
|
|
const current = new Set(cur.activeActions || []);
|
|
const want = new Set(desired);
|
|
|
|
for (const id of current) {
|
|
if (!want.has(id)) {
|
|
const res = await playbackApi.request({
|
|
version: API_VERSION,
|
|
method: "stopAction",
|
|
params: { modelId: cur.modelId || modelId, actionId: id },
|
|
});
|
|
if (!res.ok) {
|
|
setStatus(`远程动作同步失败: ${res.error?.message || "unknown"}`, "error");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
for (const id of want) {
|
|
if (!current.has(id)) {
|
|
const res = await playbackApi.request({
|
|
version: API_VERSION,
|
|
method: "playAction",
|
|
params: { modelId: cur.modelId || modelId, actionId: id },
|
|
});
|
|
if (!res.ok) {
|
|
setStatus(`远程动作同步失败: ${res.error?.message || "unknown"}`, "error");
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
lastRevision = revision;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function runCommand(msg) {
|
|
if (msg.type === "connected") {
|
|
connected = true;
|
|
setStatus("远程已连接", "ok");
|
|
if (msg.state) await applySnapshot(msg.state);
|
|
return;
|
|
}
|
|
if (msg.type === "ping") return;
|
|
if (!msg.method) return;
|
|
|
|
const revision = Number(msg.revision) || 0;
|
|
if (revision && revision <= lastRevision) return;
|
|
|
|
if (shouldSkipEcho(msg)) {
|
|
if (revision) lastRevision = Math.max(lastRevision, revision);
|
|
return;
|
|
}
|
|
|
|
if (msg.method === "syncState") {
|
|
await applySnapshot(msg.state);
|
|
return;
|
|
}
|
|
|
|
if (!REMOTE_METHODS.has(msg.method)) return;
|
|
|
|
await withRemoteApply(async () => {
|
|
const p = msg.params || {};
|
|
if (!(await ensureModel(p.modelId || p.model))) return;
|
|
await waitUntilReady();
|
|
|
|
const res = await playbackApi.request({
|
|
version: API_VERSION,
|
|
id: msg.id,
|
|
method: msg.method,
|
|
params: p,
|
|
});
|
|
if (!res.ok) {
|
|
setStatus(`远程命令失败: ${res.error?.message || "unknown"}`, "error");
|
|
return;
|
|
}
|
|
if (revision) lastRevision = Math.max(lastRevision, revision);
|
|
});
|
|
}
|
|
|
|
function enqueue(msg) {
|
|
queue.push(msg);
|
|
drain();
|
|
}
|
|
|
|
async function drain() {
|
|
if (draining) return;
|
|
draining = true;
|
|
while (queue.length) {
|
|
const msg = queue.shift();
|
|
try {
|
|
await runCommand(msg);
|
|
} catch (e) {
|
|
console.warn("[remote-bridge]", e);
|
|
setStatus(`远程执行异常: ${e.message}`, "error");
|
|
}
|
|
}
|
|
draining = false;
|
|
}
|
|
|
|
function connectSse() {
|
|
if (es) es.close();
|
|
es = new EventSource(`${API_ROOT}/events`);
|
|
es.onmessage = (ev) => {
|
|
try {
|
|
enqueue(JSON.parse(ev.data));
|
|
} catch (e) {
|
|
console.warn("[remote-bridge] bad event", e);
|
|
}
|
|
};
|
|
es.onerror = () => {
|
|
connected = false;
|
|
setStatus("远程连接断开,自动重连…", "warn");
|
|
};
|
|
}
|
|
|
|
async function fetchServerState() {
|
|
const res = await fetch(`${API_ROOT}/state?t=${Date.now()}`);
|
|
const body = await res.json();
|
|
return body.ok ? body.data : null;
|
|
}
|
|
|
|
async function syncFromServer() {
|
|
try {
|
|
const state = await fetchServerState();
|
|
if (!state || (state.revision || 0) <= 0) return state;
|
|
await applySnapshot(state);
|
|
return state;
|
|
} catch (e) {
|
|
setStatus("远程状态同步失败", "warn");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
let reportTimer = null;
|
|
function reportLocalState() {
|
|
if (applyingRemote > 0) return;
|
|
clearTimeout(reportTimer);
|
|
reportTimer = setTimeout(async () => {
|
|
const s = playbackApi.getState?.();
|
|
if (!s?.ready || applyingRemote > 0) return;
|
|
try {
|
|
await fetch(`${API_ROOT}/report`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
clientId: CLIENT_ID,
|
|
modelId: s.modelId,
|
|
activeActions: s.activeActions,
|
|
}),
|
|
});
|
|
} catch (_) { /* ignore */ }
|
|
}, 250);
|
|
}
|
|
|
|
connectSse();
|
|
setStatus("等待远程连接…", "idle");
|
|
|
|
return {
|
|
clientId: CLIENT_ID,
|
|
fetchServerState,
|
|
syncFromServer,
|
|
reportLocalState,
|
|
close: () => {
|
|
clearTimeout(reportTimer);
|
|
es?.close();
|
|
},
|
|
};
|
|
}
|