feat(n7web): enhance control workflows and status feedback
This commit is contained in:
+261
-17
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user