feat: integrate N7Cockpit remote mode control
This commit is contained in:
+136
-5
@@ -58,6 +58,11 @@ const state = {
|
||||
physicalStateSyncPromise: null,
|
||||
pendingModeProfileId: null,
|
||||
modeRunTimer: null,
|
||||
modeEventSequence: 0,
|
||||
modeEventPoller: null,
|
||||
modeEventStartedAt: Date.now(),
|
||||
activeModeExecutions: new Set(),
|
||||
renderedModeExecutions: new Set(),
|
||||
modeDraftCommand: "",
|
||||
lastSuccessfulUtterance: "",
|
||||
};
|
||||
@@ -4159,17 +4164,141 @@ function setModeChipsDisabled(disabled) {
|
||||
});
|
||||
}
|
||||
|
||||
function applyModeCommand(command, modeProfileId = null) {
|
||||
async function applyModeCommand(command, modeProfileId = null) {
|
||||
if (!command) return;
|
||||
if ($("#runButton").disabled) {
|
||||
showToast("上一条指令仍在执行,请稍候");
|
||||
return;
|
||||
}
|
||||
if (!modeProfileId) {
|
||||
$("#utterance").value = command;
|
||||
$("#requestForm").requestSubmit();
|
||||
return;
|
||||
}
|
||||
const driverOnlyCh1 = isDriverOnlyCh1Runtime();
|
||||
if (state.executionMode === "hardware"
|
||||
&& !driverOnlyCh1
|
||||
&& !isLocalDecisionHost()
|
||||
&& state.hardwareAccess?.state !== "approved") {
|
||||
showToast(state.hardwareAccess?.state === "pending"
|
||||
? "申请仍在等待本地审批"
|
||||
: "请先申请并获得本地真实台架使用权");
|
||||
return;
|
||||
}
|
||||
closeModeGallery({ restoreFocus: false });
|
||||
state.pendingModeProfileId = modeProfileId;
|
||||
if (modeProfileId) setModeExecutionState(modeProfileId, "running");
|
||||
$("#utterance").value = command;
|
||||
$("#requestForm").requestSubmit();
|
||||
setModeExecutionState(modeProfileId, "running");
|
||||
setModeChipsDisabled(true);
|
||||
try {
|
||||
const vehicleId = state.vehicleId || state.runtime?.default_vehicle_id;
|
||||
const response = await apiRequest(
|
||||
`/api/v1/vehicles/${encodeURIComponent(vehicleId)}/mode-executions`,
|
||||
{
|
||||
method: "POST",
|
||||
timeoutMs: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": createOpaqueId("web-mode"),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mode_id: modeProfileId,
|
||||
execution_target: state.executionMode,
|
||||
source: "web",
|
||||
source_device_id: "web-console",
|
||||
...(state.executionMode === "hardware" && !driverOnlyCh1
|
||||
? { hardware_client_id: state.hardwareClientId }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
const execution = await response.json();
|
||||
if (response.status === 401) {
|
||||
window.location.replace("/login");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(execution.message || execution.error || `HTTP ${response.status}`);
|
||||
state.activeModeExecutions.add(execution.execution_id);
|
||||
showToast(execution.message || `${modeChipLabel({ name: command })}已进入执行队列`);
|
||||
} catch (error) {
|
||||
setModeExecutionState(modeProfileId, "failed");
|
||||
setModeChipsDisabled(false);
|
||||
showToast(`模式提交失败:${error.message || "服务不可用"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function webModeState(status) {
|
||||
if (["queued", "validating", "executing", "verifying"].includes(status)) return "running";
|
||||
if (["succeeded", "partially_succeeded"].includes(status)) return "succeeded";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function modeExecutionTerminal(status) {
|
||||
return ["succeeded", "partially_succeeded", "failed", "cancelled", "timed_out"].includes(status);
|
||||
}
|
||||
|
||||
async function handleModeExecutionEvent(event) {
|
||||
const execution = event?.execution || event;
|
||||
if (!execution?.execution_id || !execution.mode_id) return;
|
||||
setModeExecutionState(execution.mode_id, webModeState(execution.status));
|
||||
if (modeExecutionTerminal(execution.status)) {
|
||||
state.activeModeExecutions.delete(execution.execution_id);
|
||||
} else {
|
||||
state.activeModeExecutions.add(execution.execution_id);
|
||||
}
|
||||
setModeChipsDisabled(state.activeModeExecutions.size > 0);
|
||||
$("#resultTitle").textContent = execution.message || `正在执行${execution.mode_name}`;
|
||||
|
||||
if (execution.source === "android") {
|
||||
showToast(`N7Cockpit:${execution.message || execution.mode_name}`);
|
||||
}
|
||||
if (!modeExecutionTerminal(execution.status)
|
||||
|| state.renderedModeExecutions.has(execution.execution_id)) return;
|
||||
state.renderedModeExecutions.add(execution.execution_id);
|
||||
try {
|
||||
const response = await apiRequest(
|
||||
`/api/v1/mode-executions/${encodeURIComponent(execution.execution_id)}`,
|
||||
{ cache: "no-store", timeoutMs: 10000 },
|
||||
);
|
||||
const completed = await response.json();
|
||||
if (response.ok && completed.result) {
|
||||
renderResult(completed.result);
|
||||
// renderResult updates the digital twin, but its generic domain summary must not
|
||||
// replace the stable mode identity required by Web/Android remote operation.
|
||||
$("#resultTitle").textContent = execution.message || `${execution.mode_name}执行完成`;
|
||||
$("#requestMeta").textContent = [
|
||||
execution.execution_id,
|
||||
`mode:${execution.mode_id}`,
|
||||
`source:${execution.source === "android" ? "N7Cockpit" : execution.source}`,
|
||||
execution.execution_target,
|
||||
].filter(Boolean).join(" / ");
|
||||
}
|
||||
} catch (_error) {
|
||||
// The mode card and status message still contain the terminal state.
|
||||
}
|
||||
}
|
||||
|
||||
async function pollModeExecutionEvents({ initial = false } = {}) {
|
||||
const vehicleId = state.vehicleId || state.runtime?.default_vehicle_id;
|
||||
if (!vehicleId || document.visibilityState === "hidden") return;
|
||||
try {
|
||||
const response = await apiRequest(
|
||||
`/api/v1/vehicles/${encodeURIComponent(vehicleId)}/events?after=${state.modeEventSequence}`,
|
||||
{ cache: "no-store", timeoutMs: 8000 },
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
state.modeEventSequence = Number(payload.next_sequence || state.modeEventSequence);
|
||||
const events = Array.isArray(payload.events) ? payload.events : [];
|
||||
for (const event of events) {
|
||||
const createdAt = Date.parse(event.created_at || "");
|
||||
if (initial && Number.isFinite(createdAt) && createdAt < state.modeEventStartedAt - 2000) continue;
|
||||
await handleModeExecutionEvent(event);
|
||||
}
|
||||
if (initial && Array.isArray(payload.active_executions)) {
|
||||
for (const execution of payload.active_executions) await handleModeExecutionEvent(execution);
|
||||
}
|
||||
} catch (_error) {
|
||||
// The normal connection probe remains responsible for the global offline badge.
|
||||
}
|
||||
}
|
||||
|
||||
function updateModeDraftPreview() {
|
||||
@@ -5941,6 +6070,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
|
||||
await loadAuthStatus();
|
||||
await loadRuntime();
|
||||
await pollModeExecutionEvents({ initial: true });
|
||||
state.modeEventPoller = window.setInterval(pollModeExecutionEvents, 1000);
|
||||
if (!isDriverOnlyCh1Runtime()) {
|
||||
await loadHardwareAccessStatus({ silent: true });
|
||||
state.hardwareAccessPoller = window.setInterval(
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||
<script src="/network.js?v=android-pad-1" defer></script>
|
||||
<script src="/app.js?v=dual-seat-67" defer></script>
|
||||
<script src="/app.js?v=dual-seat-69" defer></script>
|
||||
<script type="module" src="/gesture-control.js?v=android-pad-7"></script>
|
||||
<script src="/pwa.js?v=android-pad-1" defer></script>
|
||||
<script src="/console-v2.js?v=desktop-refactor-30" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user