feat(n7web): enhance control workflows and status feedback

This commit is contained in:
Miayui
2026-09-16 12:52:07 +08:00
parent 84a3cd73bb
commit 0e4328f548
45 changed files with 7317 additions and 1001 deletions
+261 -17
View File
@@ -266,7 +266,7 @@
if (isPassengerSeat) {
const passengerAxes = {
"seat.set_longitudinal_position": ["副驾座椅前后调节", "horizontal", 0],
"seat.set_cushion_tilt": ["副驾座椅高度调节", "basin", 1],
"seat.set_cushion_tilt": ["副驾座椅坐盆调节", "basin", 1],
"seat.set_backrest_recline": ["副驾座椅靠背调节", "backrest", 2],
"seat.set_legrest_height": ["副驾座椅腿托调节", "legrest", 3],
};
@@ -447,11 +447,19 @@
height: new Set(["up", "down", "stop"]),
backrest: new Set(["forward", "backward", "stop"]),
basin: new Set(["up", "down", "stop"]),
legrest: new Set(["up", "down", "forward", "backward", "stop"]),
};
if (validDirections[axis]?.has(direction)) {
if (direction === "stop") return command("主驾座椅调节", `seat driver ${axis} stop`);
const axisCategory = {
horizontal: "主驾座椅前后调节",
height: "主驾座椅坐盆高度调节",
basin: "主驾座椅坐盆调节",
backrest: "主驾座椅靠背调节",
legrest: "主驾座椅腿托调节",
}[axis] || "主驾座椅调节";
if (direction === "stop") return command(axisCategory, `seat driver ${axis} stop`);
const duration = boundedInteger(parameters.duration_ms, 100, 2000);
if (duration !== null) return command("主驾座椅调节", `seat driver ${axis} ${direction} ${duration}`);
if (duration !== null) return command(axisCategory, `seat driver ${axis} ${direction} ${duration}`);
}
}
if (capability === "seat.set_driver_position_native") {
@@ -531,6 +539,7 @@
}
});
const submittedStates = new Set(["submitted", "queued", "published", "sent", "delivered"]);
const abortedStates = new Set(["aborted", "cancelled", "canceled", "skipped"]);
const deliveryForOperation = (operation) => {
const capability = String(operation?.capability || "");
const operationId = String(operation?.id || "");
@@ -555,30 +564,59 @@
|| telemetry.feedback_observed === true
|| telemetry.physical_effect_confirmed === true
);
const executionStatus = String(execution?.status || "").toLowerCase();
const verificationStatus = String(telemetry.verification_status || "").toLowerCase();
const failureMessage = String(execution?.message || "").trim();
const executionFailed = execution?.status === "failed"
|| telemetry.verification_status === "failed_or_blocked";
const safetyBlocked = executionFailed && !submitted && (
/no fresh valid 0x41a shade position/i.test(failureMessage)
|| /refusing (?:blind )?motion/i.test(failureMessage)
|| /thermal protection/i.test(failureMessage)
const aborted = abortedStates.has(executionStatus);
const executionFailed = executionStatus === "failed"
|| verificationStatus === "failed_or_blocked";
const missingWindowFeedback = /0x388/i.test(failureMessage) && (
/no fresh valid/i.test(failureMessage)
|| /未收到.*(?:有效|新鲜).*车窗位置/.test(failureMessage)
);
const deliveryState = safetyBlocked
const missingShadeFeedback = /0x41a/i.test(failureMessage) && (
/no fresh valid/i.test(failureMessage)
|| /未收到.*(?:有效|新鲜).*遮阳帘.*位置/.test(failureMessage)
);
const safetyBlocked = executionFailed && !submitted && (
missingWindowFeedback
|| missingShadeFeedback
|| /refusing (?:blind )?motion/i.test(failureMessage)
|| /拒绝(?:启动机构|盲动|执行)/.test(failureMessage)
|| /thermal protection|热保护/i.test(failureMessage)
);
const dispatchFailed = executionFailed && !submitted && !safetyBlocked;
const verified = !executionFailed && !aborted && (
verificationStatus === "verified_success"
|| telemetry.physical_effect_confirmed === true
|| telemetry.feedback_match === true
);
const deliveryState = aborted
? "aborted"
: safetyBlocked
? "blocked"
: executionFailed
? "failed"
: submitted
? "submitted"
: "not_submitted";
const deliveryLabel = safetyBlocked
const deliveryLabel = aborted
? "已中止"
: safetyBlocked
? "安全阻止"
: executionFailed
: dispatchFailed
? "下发失败"
: executionFailed
? "执行失败"
: verified
? "执行已确认"
: submitted
? "下发"
? "已下发 · 待状态确认"
: "未下发";
const deliveryDetail = /no fresh valid 0x41a shade position/i.test(failureMessage)
? "未收到遮阳帘 CAN1 / 0x41A 实时位置,无法判断移动方向,系统已阻止盲动"
const deliveryDetail = missingWindowFeedback
? "未收到车窗 CAN1 / 0x388 实时位置,系统已阻止车窗盲动"
: missingShadeFeedback
? "未收到遮阳帘 CAN1 / 0x41A 实时位置,无法判断移动方向,系统已阻止盲动"
: failureMessage;
const transportLabel = capability === "infrared.emit"
? "红外 TCP"
@@ -614,18 +652,32 @@
.filter((frame) => frame.value);
const command = String(presentation?.actual_command || fallback.command || "").trim();
if (!command) return;
const delivery = deliveryForOperation(operation);
const explicitDeliveryState = String(presentation?.delivery_state || "").trim();
const explicitDeliveryLabel = String(presentation?.delivery_label || "").trim();
const explicitDeliveryDetail = String(presentation?.delivery_detail || "").trim();
const explicitDeliveryTransport = String(presentation?.delivery_transport || "").trim();
commands.push({
...fallback,
channel,
command,
category: String(presentation?.category || fallback.category || "控制指令"),
...(frames.length ? {
frame: frames[0].value,
frameLabel: frames[0].label,
frames,
} : {}),
semantic: semanticControlInstruction(operation),
semantic: String(
presentation?.semantic_instruction || semanticControlInstruction(operation),
),
operationRef: String(operation.id || ""),
...deliveryForOperation(operation),
...delivery,
...(explicitDeliveryState ? { deliveryState: explicitDeliveryState } : {}),
...(explicitDeliveryLabel ? { deliveryLabel: explicitDeliveryLabel } : {}),
...(explicitDeliveryDetail ? { deliveryDetail: explicitDeliveryDetail } : {}),
...(explicitDeliveryTransport ? {
deliveryTransport: explicitDeliveryTransport,
} : {}),
});
});
} else {
@@ -669,6 +721,7 @@
saved_mode_command_flow: "模式卡片",
local_rule: "本地规则",
speech_semantic: "语音语义",
vehicle_mqtt_extension: "车端 MQTT",
}[source] || "云端模型";
}
@@ -874,6 +927,10 @@
<b class="v2-can-command-delivery" data-delivery-state="${escapeHtml(item.deliveryState)}" title="${escapeHtml([item.deliveryTransport, item.deliveryLabel, item.deliveryDetail].filter(Boolean).join(" · "))}">${escapeHtml(item.deliveryLabel)}</b>
<i class="v2-can-command-category">${escapeHtml(item.category)}</i>
</span>
${item.deliveryDetail && ["blocked", "failed", "aborted"].includes(item.deliveryState) ? `
<small class="v2-can-command-delivery-detail">
<b>状态说明</b>${escapeHtml(item.deliveryDetail)}
</small>` : ""}
<div class="v2-can-command-layer v2-can-command-semantic">
<small>语义控制指令</small>
<code>${escapeHtml(item.semantic)}</code>
@@ -1214,6 +1271,13 @@
logKeys: new Set(),
extensionFingerprint: "",
};
const extensionControlSync = {
revision: -1,
requestId: "",
running: false,
stopped: true,
controller: null,
};
const nonDispatchTaskSources = new Set(["mode_editor_compile"]);
function isDispatchTask(task = {}) {
@@ -2074,6 +2138,183 @@
}
}
function extensionControlDelivery(action = {}) {
const state = String(action.state || "pending");
const defaults = {
pending: ["not_submitted", "等待下发"],
executing: ["executing", "正在下发"],
submitted_unverified: ["submitted", "已下发 · 物理效果未确认"],
verified_success: ["submitted", "执行已确认"],
failed: ["failed", "下发失败"],
aborted: ["aborted", "已中止"],
};
const [deliveryState, deliveryLabel] = defaults[state] || defaults.pending;
return {
deliveryState,
deliveryLabel: String(action.state_label || deliveryLabel),
deliveryDetail: String(action.state_detail || ""),
};
}
function extensionControlResult(snapshot = {}) {
const requestId = String(snapshot.last_request_id || "");
const actions = Array.isArray(snapshot.action_statuses)
? snapshot.action_statuses
: [];
return {
request_id: requestId,
utterance: "车端 MQTT 外设控制",
status: snapshot.state === "failed" ? "failed" : "succeeded",
execution_mode: "hardware",
control_transport: "extension_mqtt",
orchestration_route: "vehicle_mqtt_extension_control",
validated_control_commands: actions.map((action, position) => {
const index = Number.isInteger(Number(action.index))
? Number(action.index)
: position;
const atMs = Math.max(0, Number(action.at_ms) || 0);
const slot = Number(action.slot);
const extensionName = String(action.extension_name || "红外外设");
const actionName = String(action.action || "红外动作");
const targetScope = String(action.target_scope || "external-device");
const delivery = extensionControlDelivery(action);
return {
domain: "self_extension",
channel: "IR",
category: `${extensionName} · target_scope=${targetScope} · at_ms=${atMs}`,
semantic_instruction: `${extensionName} · ${actionName}`,
actual_command: `infrared emit ${slot}`,
delivery_state: delivery.deliveryState,
delivery_label: delivery.deliveryLabel,
delivery_detail: [delivery.deliveryDetail, `at_ms=${atMs}`]
.filter(Boolean)
.join(" · "),
delivery_transport: "车端 MQTT → 红外 TCP",
frames: [{
label: "GSCU1B TCP",
value: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }),
}],
operation: {
id: `${requestId || "extension-ctl"}:${index}`,
domain: "self_extension",
capability: "infrared.emit",
target: { device: targetScope, name: extensionName },
parameters: { action: actionName, slot },
parse_source: "vehicle_mqtt_extension",
},
};
}),
};
}
function renderExtensionControlRejection(snapshot = {}) {
const panel = $("#v2CanCommandPanel");
const list = $("#v2CanCommandList");
const count = $("#v2CanCommandCount");
if (!panel || !list || !count) return;
count.textContent = "0";
panel.dataset.state = "error";
panel.open = true;
list.innerHTML = `<p>车端 MQTT 外设控制已拒绝:${escapeHtml(snapshot.last_error || "消息未通过校验")}</p>`;
if (ui.sidebarCollapsed) setSidebarCollapsed(false);
requestAnimationFrame(syncSidebarLogBaseline);
}
function applyExtensionControlSnapshot(snapshot = {}) {
const revision = Number(snapshot.revision);
if (!Number.isInteger(revision)) return;
if (revision < extensionControlSync.revision) {
extensionControlSync.revision = -1;
extensionControlSync.requestId = "";
}
if (revision <= extensionControlSync.revision) return;
extensionControlSync.revision = revision;
const requestId = String(snapshot.last_request_id || "");
const state = String(snapshot.state || "");
const actions = Array.isArray(snapshot.action_statuses)
? snapshot.action_statuses
: [];
if (requestId) extensionControlSync.requestId = requestId;
if (actions.length) {
renderParsedCanCommands(extensionControlResult(snapshot), { reveal: true });
appendSyncLog({
key: `extension-control:${requestId}:${state}:${snapshot.executed_action_count || 0}`,
category: "extension",
title: state === "completed"
? "车端 MQTT 外设控制已下发"
: state === "failed"
? "车端 MQTT 外设控制下发失败"
: "正在执行车端 MQTT 外设控制",
detail: `${actions.length} 条红外指令 · ${snapshot.last_error || state}`,
status: state === "failed" ? "error" : state === "completed" ? "success" : "info",
});
return;
}
if (requestId && ["rejected", "queue_full"].includes(state)) {
renderExtensionControlRejection(snapshot);
appendSyncLog({
key: `extension-control:${requestId}:${state}`,
category: "extension",
title: "车端 MQTT 外设控制已拒绝",
detail: String(snapshot.last_error || "消息未通过校验"),
status: "error",
});
}
}
const extensionControlRetryDelay = () => new Promise((resolve) => {
window.setTimeout(resolve, 1000);
});
async function monitorExtensionControl() {
if (extensionControlSync.running) return;
extensionControlSync.running = true;
extensionControlSync.stopped = false;
try {
while (!extensionControlSync.stopped) {
const controller = new AbortController();
extensionControlSync.controller = controller;
const query = new URLSearchParams({
after_revision: String(extensionControlSync.revision),
timeout_ms: "25000",
});
try {
const response = await fetch(`/api/extension/control/status?${query}`, {
credentials: "same-origin",
cache: "no-store",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
applyExtensionControlSnapshot(await response.json());
} catch (error) {
if (extensionControlSync.stopped || error?.name === "AbortError") break;
console.warn("[extension-control-sync]", error);
await extensionControlRetryDelay();
} finally {
if (extensionControlSync.controller === controller) {
extensionControlSync.controller = null;
}
}
}
} finally {
extensionControlSync.running = false;
}
}
function startExtensionControlMonitor() {
if (extensionControlSync.running) return;
extensionControlSync.stopped = false;
void monitorExtensionControl();
}
function stopExtensionControlMonitor() {
extensionControlSync.stopped = true;
extensionControlSync.controller?.abort();
extensionControlSync.controller = null;
}
function applyDisplaySnapshot(snapshot) {
const synchronizedCatalog = Array.isArray(snapshot?.extension?.catalog)
? snapshot.extension.catalog
@@ -2461,6 +2702,9 @@
appendSyncLog({ key: "client:ready", category: "sync", title: "同步控制台已就绪", detail: "正在连接车机任务与设备状态服务", status: "info" });
pollVehicleDisplaySync();
displaySync.pollTimer = window.setInterval(pollVehicleDisplaySync, 2000);
startExtensionControlMonitor();
window.addEventListener("pagehide", stopExtensionControlMonitor);
window.addEventListener("pageshow", startExtensionControlMonitor);
const updateClock = () => {
if ($("#v2SyncClock")) $("#v2SyncClock").textContent = new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,