添加NFC绑定红外设备方式,融合车端设备和扩展设备统一控制

This commit is contained in:
minghanchen
2026-09-01 11:04:59 +08:00
parent 6d75944c0f
commit ca26259e63
14 changed files with 3694 additions and 316 deletions
+41
View File
@@ -7225,6 +7225,32 @@ body.device-power-drawer-open {
border-color: rgba(122, 75, 0, 0.16); border-color: rgba(122, 75, 0, 0.16);
} }
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-delivery {
flex: 0 0 auto;
padding: 1px 5px;
color: #687776;
background: #e8eceb;
border: 1px solid rgba(91, 107, 105, 0.14);
border-radius: 999px;
font-size: 7px;
font-weight: 900;
line-height: 1.5;
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-delivery[data-delivery-state="submitted"] {
color: #087048;
background: #d9f3e5;
border-color: rgba(8, 112, 72, 0.16);
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-category {
min-width: 0;
overflow: hidden;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] { .console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] {
background: #f7f4ec; background: #f7f4ec;
} }
@@ -7235,6 +7261,21 @@ body.device-power-drawer-open {
border-color: rgba(138, 90, 16, 0.13); border-color: rgba(138, 90, 16, 0.13);
} }
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="IR"] {
background: #f5f1fb;
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="IR"] .v2-can-command-channel {
color: #6c3aa1;
background: #eadcf8;
border-color: rgba(108, 58, 161, 0.14);
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="IR"] code,
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="IR"] .v2-can-command-frame {
color: #664086;
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li code { .console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li code {
color: #075f5a; color: #075f5a;
font: 750 9px/1.45 "SFMono-Regular", Consolas, monospace; font: 750 9px/1.45 "SFMono-Regular", Consolas, monospace;
+94 -21
View File
@@ -44,7 +44,9 @@
if (validatedCommandContext.requestId && requestId if (validatedCommandContext.requestId && requestId
&& validatedCommandContext.requestId !== requestId) return false; && validatedCommandContext.requestId !== requestId) return false;
if (validatedCommandContext.utterance && utterance if (validatedCommandContext.utterance && utterance
&& validatedCommandContext.utterance !== utterance) return false; && validatedCommandContext.utterance !== utterance
&& !validatedCommandContext.utterance.includes(utterance)
&& !utterance.includes(validatedCommandContext.utterance)) return false;
return true; return true;
} }
@@ -105,6 +107,22 @@
return `0x300 · ${hexByte(opcode)} ${hexByte(control)} <SEQ_LO> <SEQ_HI> ${littleEndian16(paramA)} ${littleEndian16(paramB)}`; return `0x300 · ${hexByte(opcode)} ${hexByte(control)} <SEQ_LO> <SEQ_HI> ${littleEndian16(paramA)} ${littleEndian16(paramB)}`;
}; };
if (capability === "infrared.emit") {
const slot = boundedInteger(parameters.slot, 1, 248);
const action = String(parameters.action || "红外动作").trim();
const deviceName = String(operation.target?.name || operation.target?.device || "红外外设").trim();
if (slot !== null) {
return {
channel: "IR",
category: `${deviceName} · ${action}`,
command: `infrared emit ${slot}`,
frame: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }),
frameLabel: "GSCU1B TCP",
...(parseSource ? { parseSource } : {}),
};
}
}
const isPassengerSeat = new Set(["passenger", "front-right"]).has(target); const isPassengerSeat = new Set(["passenger", "front-right"]).has(target);
if (isPassengerSeat) { if (isPassengerSeat) {
const passengerAxes = { const passengerAxes = {
@@ -324,23 +342,74 @@
function parsedCanCommands(result = {}) { function parsedCanCommands(result = {}) {
const configurations = result.function_configs || {}; const configurations = result.function_configs || {};
const plans = result.command_plans || {}; const configurationEntries = Object.entries(configurations);
const planKeys = new Set(Object.keys(plans)); if (!configurationEntries.length && result.function_config) {
const hasCompiledPlan = planKeys.size > 0 || Boolean(result.command_plan); configurationEntries.push(["default", result.function_config]);
if (!hasCompiledPlan) return []; }
const executionResults = [
...(result.execution_report?.spec?.results || []),
...(result.operation_results || []),
...Object.values(result.phase_execution_reports || {}).flatMap(
(report) => report?.spec?.results || [],
),
];
const executionByRef = new Map();
executionResults.forEach((execution) => {
const ref = String(execution?.ref || "");
if (!ref) return;
const previous = executionByRef.get(ref);
if (!previous || (!previous.telemetry && execution?.telemetry)) {
executionByRef.set(ref, execution);
}
});
const submittedStates = new Set(["submitted", "queued", "published", "sent", "delivered"]);
const deliveryForOperation = (operation) => {
const capability = String(operation?.capability || "");
const operationId = String(operation?.id || "");
const execution = executionByRef.get(operationId) || executionResults.find((candidate) => {
const ref = String(candidate?.ref || "");
return ref && operationId && (
operationId.startsWith(`${ref}-`) || ref.startsWith(`${operationId}-`)
);
});
const telemetry = execution?.telemetry || {};
const mqttMessages = Array.isArray(telemetry.mqtt_messages) ? telemetry.mqtt_messages : [];
const deliveryStates = [
telemetry.delivery_status,
...mqttMessages.map((message) => message?.delivery_status),
].map((value) => String(value || "").toLowerCase());
const submitted = result.execution_mode === "hardware"
&& telemetry.simulated !== true
&& (
deliveryStates.some((value) => submittedStates.has(value))
|| (Array.isArray(telemetry.sent_frames) && telemetry.sent_frames.length > 0)
|| telemetry.gateway_acknowledged === true
|| telemetry.feedback_observed === true
|| telemetry.physical_effect_confirmed === true
);
const transportLabel = capability === "infrared.emit"
? "红外 TCP"
: result.control_transport === "wireless_mqtt" ? "无线 CAN" : "有线 CAN";
return {
deliveryState: submitted ? "submitted" : "not_submitted",
deliveryLabel: submitted ? "下发" : "未下发",
deliveryTransport: transportLabel,
};
};
const commands = []; const commands = [];
Object.entries(configurations).forEach(([configKey, configuration]) => { configurationEntries.forEach(([_configKey, configuration]) => {
if (planKeys.size > 0 && !planKeys.has(configKey)) return;
(configuration?.spec?.operations || []).forEach((operation) => { (configuration?.spec?.operations || []).forEach((operation) => {
const item = parsedCanCommandForOperation(operation); const item = parsedCanCommandForOperation(operation);
if (item) commands.push(item); if (item) commands.push({ ...item, ...deliveryForOperation(operation) });
}); });
}); });
const unique = new Map(); const unique = new Map();
commands.forEach((item) => { commands.forEach((item) => {
const key = `${item.channel}:${item.command}`; const key = `${item.channel}:${item.command}`;
const previous = unique.get(key); const previous = unique.get(key);
if (!previous || (previous.parseSource !== "local_rule" && item.parseSource === "local_rule")) { if (!previous
|| (previous.deliveryState !== "submitted" && item.deliveryState === "submitted")
|| (previous.parseSource !== "local_rule" && item.parseSource === "local_rule")) {
unique.set(key, item); unique.set(key, item);
} }
}); });
@@ -357,17 +426,18 @@
panel.dataset.state = commands.length ? "ready" : message ? "pending" : "empty"; panel.dataset.state = commands.length ? "ready" : message ? "pending" : "empty";
if (commands.length) { if (commands.length) {
list.innerHTML = `<ol>${commands.map((item) => ` list.innerHTML = `<ol>${commands.map((item) => `
<li data-can-channel="${escapeHtml(item.channel)}"> <li data-can-channel="${escapeHtml(item.channel)}" data-delivery-state="${escapeHtml(item.deliveryState)}">
<span> <span>
<b class="v2-can-command-channel">${escapeHtml(item.channel)}</b> <b class="v2-can-command-channel">${escapeHtml(item.channel)}</b>
${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""} ${item.parseSource ? `<b class="v2-can-command-source" data-parse-source="${escapeHtml(item.parseSource)}">${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}</b>` : ""}
${escapeHtml(item.category)} <b class="v2-can-command-delivery" data-delivery-state="${escapeHtml(item.deliveryState)}" title="${escapeHtml(`${item.deliveryTransport} · ${item.deliveryLabel}`)}">${escapeHtml(item.deliveryLabel)}</b>
<i class="v2-can-command-category">${escapeHtml(item.category)}</i>
</span> </span>
<code>${escapeHtml(item.command)}</code> <code>${escapeHtml(item.command)}</code>
${item.frame ? `<small class="v2-can-command-frame">固件原生 CAN0 · ${escapeHtml(item.frame)}</small>` : ""} ${item.frame ? `<small class="v2-can-command-frame">${escapeHtml(item.frameLabel || "固件原生 CAN0")} · ${escapeHtml(item.frame)}</small>` : ""}
</li>`).join("")}</ol>`; </li>`).join("")}</ol>`;
} else { } else {
list.innerHTML = `<p>${escapeHtml(message || "本次请求未生成已验证的 CAN 控制指令")}</p>`; list.innerHTML = `<p>${escapeHtml(message || "本次请求未解析出控制指令")}</p>`;
} }
requestAnimationFrame(syncSidebarLogBaseline); requestAnimationFrame(syncSidebarLogBaseline);
} }
@@ -380,15 +450,18 @@
const items = commands.map((command) => ({ const items = commands.map((command) => ({
channel: command.startsWith("seat passenger ") ? "CAN0" : "CAN1", channel: command.startsWith("seat passenger ") ? "CAN0" : "CAN1",
command, command,
deliveryState: "not_submitted",
deliveryLabel: "未下发",
})); }));
count.textContent = String(items.length); count.textContent = String(items.length);
panel.dataset.state = "ready"; panel.dataset.state = "ready";
list.innerHTML = `<ol>${items.map((item) => ` list.innerHTML = `<ol>${items.map((item) => `
<li data-can-channel="${item.channel}"> <li data-can-channel="${item.channel}" data-delivery-state="${item.deliveryState}">
<span> <span>
<b class="v2-can-command-channel">${item.channel}</b> <b class="v2-can-command-channel">${item.channel}</b>
<b class="v2-can-command-source" data-parse-source="direct_control">设备控件</b> <b class="v2-can-command-source" data-parse-source="direct_control">设备控件</b>
${escapeHtml(deviceName)} <b class="v2-can-command-delivery" data-delivery-state="${item.deliveryState}" title="尚未进入 CAN 传输链路">${item.deliveryLabel}</b>
<i class="v2-can-command-category">${escapeHtml(deviceName)}</i>
</span> </span>
<code>${escapeHtml(item.command)}</code> <code>${escapeHtml(item.command)}</code>
</li>`).join("")}</ol>`; </li>`).join("")}</ol>`;
@@ -1205,7 +1278,7 @@
: trace.length : trace.length
? trace.slice(-4).map((item) => item.label || item.stage || item.event || "执行步骤") ? trace.slice(-4).map((item) => item.label || item.stage || item.event || "执行步骤")
: domains.length : domains.length
? ["接收编排指令", `编排 ${domains.length} 个设备域`, "下发执行计划", "同步执行结果"] ? ["接收编排指令", `编排 ${domains.length} 个设备域`, "生成执行计划", "同步执行结果"]
: ["等待车机或网页端下发编排任务"]; : ["等待车机或网页端下发编排任务"];
timeline.innerHTML = stages.map((stage, index) => ` timeline.innerHTML = stages.map((stage, index) => `
<span class="${task?.status === "running" && index === stages.length - 1 ? "active" : "done"}"> <span class="${task?.status === "running" && index === stages.length - 1 ? "active" : "done"}">
@@ -1258,7 +1331,7 @@
// `running` snapshot for the same task must not erase those commands. // `running` snapshot for the same task must not erase those commands.
if (!validatedCommandsBelongToTask(task)) { if (!validatedCommandsBelongToTask(task)) {
resetValidatedCommandContext(); resetValidatedCommandContext();
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" }); renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" });
} }
} else if (result) { } else if (result) {
const commands = parsedCanCommands(result); const commands = parsedCanCommands(result);
@@ -1451,7 +1524,7 @@
insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field"); insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field");
const canCommandPanel = make("details", "v2-can-command-panel", ` const canCommandPanel = make("details", "v2-can-command-panel", `
<summary> <summary>
<span><i data-lucide="binary"></i><b>已解析 CAN 控制指令</b></span> <span><i data-lucide="binary"></i><b>已解析控制指令</b></span>
<strong id="v2CanCommandCount">0</strong> <strong id="v2CanCommandCount">0</strong>
<i data-lucide="chevron-down"></i> <i data-lucide="chevron-down"></i>
</summary> </summary>
@@ -1550,7 +1623,7 @@
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs); $("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
$("#requestForm")?.addEventListener("submit", () => { $("#requestForm")?.addEventListener("submit", () => {
resetValidatedCommandContext(); resetValidatedCommandContext();
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" }); renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" });
}); });
window.addEventListener("cognitive-agent:validated-can-commands", (event) => { window.addEventListener("cognitive-agent:validated-can-commands", (event) => {
const result = event.detail || {}; const result = event.detail || {};
@@ -1558,7 +1631,7 @@
if (validatedCommandsVisible) rememberValidatedCommandContext(result); if (validatedCommandsVisible) rememberValidatedCommandContext(result);
else resetValidatedCommandContext(); else resetValidatedCommandContext();
renderParsedCanCommands(result, { renderParsedCanCommands(result, {
message: "已完成白名单校验,正在下发控制指令…", message: "控制指令已解析,正在等待传输结果…",
}); });
}); });
window.addEventListener("cognitive-agent:result", (event) => { window.addEventListener("cognitive-agent:result", (event) => {
@@ -1591,7 +1664,7 @@
}); });
window.addEventListener("cognitive-agent:request-complete", (event) => { window.addEventListener("cognitive-agent:request-complete", (event) => {
if (event.detail?.error && !validatedCommandsVisible) { if (event.detail?.error && !validatedCommandsVisible) {
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" }); renderParsedCanCommands(null, { message: "本次请求未生成控制指令" });
} }
if (event.detail?.error) { if (event.detail?.error) {
const error = event.detail.error; const error = event.detail.error;
+4 -4
View File
@@ -10,8 +10,8 @@
<link rel="icon" href="/icons/app-icon-192.png" /> <link rel="icon" href="/icons/app-icon-192.png" />
<title>511工程-智能座舱</title> <title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-16" /> <link rel="stylesheet" href="/styles.css?v=dual-seat-16" />
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-41" /> <link rel="stylesheet" href="/console-v2.css?v=delivery-state-43" />
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-37" /> <link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-39" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script> <script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script> <script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script> <script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
@@ -20,8 +20,8 @@
<script src="/app.js?v=dual-seat-107" defer></script> <script src="/app.js?v=dual-seat-107" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script> <script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script> <script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=task-outcome-47" defer></script> <script src="/console-v2.js?v=delivery-state-49" defer></script>
<script src="/self-extension.js?v=gscu-tcp-37" defer></script> <script src="/self-extension.js?v=gscu-tcp-40" defer></script>
</head> </head>
<body class="console-v2"> <body class="console-v2">
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite"> <div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
+182 -7
View File
@@ -2383,7 +2383,7 @@ form > .v2-extension-primary {
.v2-gscu-actions > header { .v2-gscu-actions > header {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 110px minmax(150px, 0.7fr); grid-template-columns: minmax(110px, 0.65fr) 78px minmax(120px, 0.75fr) repeat(2, minmax(145px, 1fr));
align-items: end; align-items: end;
gap: 12px; gap: 12px;
} }
@@ -2455,9 +2455,9 @@ form > .v2-extension-primary {
.v2-gscu-command-catalog > header small { color: #7b8b89; font-size: 8px; } .v2-gscu-command-catalog > header small { color: #7b8b89; font-size: 8px; }
#extensionGscuNamedCommands { #extensionGscuNamedCommands {
max-height: 180px; max-height: min(42vh, 360px);
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
overflow: auto; overflow: auto;
@@ -2475,7 +2475,66 @@ form > .v2-extension-primary {
text-align: center; text-align: center;
} }
#extensionGscuNamedCommands > article { .v2-gscu-device-group {
min-width: 0;
padding: 9px;
background: #f2f7f6;
border: 1px solid #d8e5e2;
border-radius: 11px;
}
.v2-gscu-device-group > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 1px 2px 8px;
}
.v2-gscu-device-group > header > div {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: baseline;
gap: 2px 8px;
}
.v2-gscu-device-group > header small {
color: #6a9691;
font: 800 7px "SFMono-Regular", Consolas, monospace;
letter-spacing: 0.08em;
}
.v2-gscu-device-group > header strong {
overflow: hidden;
color: #234d49;
font: 850 11px "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-gscu-device-group > header span {
grid-column: 1 / -1;
overflow: hidden;
color: #728784;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-gscu-device-group > header > b {
flex: none;
color: #4d7773;
font-size: 8px;
}
.v2-gscu-device-command-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.v2-gscu-device-command-grid > article {
display: grid; display: grid;
gap: 8px; gap: 8px;
padding: 10px; padding: 10px;
@@ -2537,9 +2596,28 @@ form > .v2-extension-primary {
#extensionGscuNamedCommands button.danger { color: #a64639; border-color: #ebcfc9; } #extensionGscuNamedCommands button.danger { color: #a64639; border-color: #ebcfc9; }
.v2-gscu-result {
margin-top: 12px;
border: 1px solid #d7e4e1;
border-radius: 9px;
background: #fff;
}
.v2-gscu-result summary {
padding: 10px 12px;
color: #607674;
font-size: 9px;
font-weight: 850;
cursor: pointer;
}
.v2-gscu-result[open] summary {
border-bottom: 1px solid #d7e4e1;
}
#extensionGscuTcpResult { #extensionGscuTcpResult {
max-height: 170px; max-height: 170px;
margin: 12px 0 0; margin: 0;
padding: 10px; padding: 10px;
overflow: auto; overflow: auto;
color: #d7f4ef; color: #d7f4ef;
@@ -2556,7 +2634,7 @@ form > .v2-extension-primary {
.v2-gscu-action-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .v2-gscu-action-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.v2-gscu-advanced[open] { grid-template-columns: 1fr; } .v2-gscu-advanced[open] { grid-template-columns: 1fr; }
.v2-gscu-actions > header { grid-template-columns: 1fr; } .v2-gscu-actions > header { grid-template-columns: 1fr; }
#extensionGscuNamedCommands { grid-template-columns: 1fr; } .v2-gscu-device-command-grid { grid-template-columns: 1fr; }
} }
.v2-extension-synced-device { .v2-extension-synced-device {
@@ -3140,6 +3218,12 @@ form > .v2-extension-primary {
border-top: 1px solid #e3ecea; border-top: 1px solid #e3ecea;
} }
.v2-nfc-existing > small {
color: #7c908d;
font-size: 9px;
line-height: 1.5;
}
.v2-nfc-existing > div { .v2-nfc-existing > div {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -3178,6 +3262,90 @@ form > .v2-extension-primary {
font-weight: 850; font-weight: 850;
} }
.v2-nfc-select-all,
.v2-nfc-command-option {
min-width: 0;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 9px;
padding: 11px 12px;
color: #294e4a;
background: #f7faf9;
border: 1px solid #dce8e5;
border-radius: 10px;
cursor: pointer;
}
.v2-nfc-select-all {
margin-bottom: 8px;
background: #edf7f4;
border-color: #cfe4df;
}
.v2-nfc-select-all input,
.v2-nfc-command-option input {
width: 17px;
height: 17px;
margin: 0;
accent-color: #278f84;
}
.v2-nfc-select-all > span,
.v2-nfc-command-option > span {
min-width: 0;
display: grid;
gap: 3px;
}
.v2-nfc-select-all strong,
.v2-nfc-command-option strong {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-nfc-select-all small,
.v2-nfc-command-option small {
overflow: hidden;
color: #718885;
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-nfc-command-list {
max-height: min(34vh, 290px);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
overflow: auto;
}
.v2-nfc-command-option:has(input:not(:checked)) {
color: #778a88;
background: #fafbfb;
border-style: dashed;
}
.v2-nfc-no-devices {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
padding: 14px;
color: #6b817f;
background: #f7f9f9;
border: 1px dashed #d6e2df;
border-radius: 11px;
}
.v2-nfc-no-devices > svg { width: 19px; flex: none; }
.v2-nfc-no-devices > div { display: grid; gap: 3px; }
.v2-nfc-no-devices strong { color: #355956; font-size: 11px; }
.v2-nfc-no-devices span { font-size: 9px; line-height: 1.5; }
.v2-nfc-capability-grid { .v2-nfc-capability-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -3282,6 +3450,12 @@ form > .v2-extension-primary {
margin-top: 15px; margin-top: 15px;
} }
.v2-nfc-recognized-actions > span {
align-self: center;
color: #718784;
font-size: 9px;
}
.v2-nfc-recognized-actions button { .v2-nfc-recognized-actions button {
min-height: 40px; min-height: 40px;
} }
@@ -3349,7 +3523,8 @@ form > .v2-extension-primary {
} }
.v2-nfc-create-grid, .v2-nfc-create-grid,
.v2-nfc-capability-grid { .v2-nfc-capability-grid,
.v2-nfc-command-list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
+394 -145
View File
@@ -476,14 +476,12 @@
saved.infraredCommands = Array.isArray(saved.infraredCommands) saved.infraredCommands = Array.isArray(saved.infraredCommands)
? saved.infraredCommands.filter((item) => ( ? saved.infraredCommands.filter((item) => (
validSlot(item?.slot) validSlot(item?.slot)
&& typeof item?.name === "string" && (
&& item.name.trim() String(item?.name || "").trim()
)).map((item) => ({ || normalizedInfraredNames(item?.object_names ?? item?.objectNames).length
slot: Number(item.slot), || normalizedInfraredNames(item?.action_names ?? item?.actionNames).length
name: item.name.trim().slice(0, 48), )
source: String(item.source || "tcp"), )).map(normalizedInfraredSlotRecord)
learnedAt: item.learnedAt || null,
}))
: []; : [];
saved.selectedDeviceByType = saved.selectedDeviceByType || {}; saved.selectedDeviceByType = saved.selectedDeviceByType || {};
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 }; saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 };
@@ -724,7 +722,7 @@
<button class="v2-extension-primary" type="submit" id="extensionGscuTcpApply">应用并开始监听</button> <button class="v2-extension-primary" type="submit" id="extensionGscuTcpApply">应用并开始监听</button>
</form> </form>
<section class="v2-gscu-actions"> <section class="v2-gscu-actions">
<header><div><strong>TCP 动作调试</strong><small>与 gscu1b_control.py tcp 使用相同 JSON 报文</small></div><label><span>红外槽位</span><input id="extensionGscuSlot" type="number" min="1" max="248" value="1" /></label><label><span>指令名称</span><input id="extensionGscuCommandName" maxlength="48" placeholder="例如:客厅空调开启" /></label></header> <header><div><strong>TCP 动作调试</strong><small>设备 ID 唯一标识控制对象;对象与动作可填写多个自然语言别名</small></div><label><span>红外槽位</span><input id="extensionGscuSlot" type="number" min="1" max="248" value="1" /></label><label><span>设备 ID</span><input id="extensionGscuDeviceId" maxlength="64" placeholder="toy-light-001" /></label><label><span>控制对象名称</span><input id="extensionGscuObjectNames" maxlength="240" placeholder="闪烁灯、闪光灯、闪灯玩具" /></label><label><span>执行动作</span><input id="extensionGscuActionNames" maxlength="240" placeholder="打开、开启、启动" /></label></header>
<div class="v2-gscu-action-grid"> <div class="v2-gscu-action-grid">
<button type="button" data-gscu-action="info">查询设备</button> <button type="button" data-gscu-action="info">查询设备</button>
<button type="button" data-gscu-action="learn">开始学习</button> <button type="button" data-gscu-action="learn">开始学习</button>
@@ -744,8 +742,8 @@
<label><span>恢复出厂确认</span><input id="extensionGscuDangerConfirm" autocomplete="off" placeholder="恢复出厂请输入 RESET_DEVICE" /></label> <label><span>恢复出厂确认</span><input id="extensionGscuDangerConfirm" autocomplete="off" placeholder="恢复出厂请输入 RESET_DEVICE" /></label>
<div><button class="danger" type="button" data-gscu-action="erase">擦除全部红外码</button><button class="danger" type="button" data-gscu-action="reset">恢复出厂设置</button></div> <div><button class="danger" type="button" data-gscu-action="erase">擦除全部红外码</button><button class="danger" type="button" data-gscu-action="reset">恢复出厂设置</button></div>
</details> </details>
<section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong><small>槽位与名称保存在本机数据库,并按槽位号排列</small></header><div id="extensionGscuNamedCommands"></div></section> <section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong><small>按设备 ID 分组;对象名称用于自然语言识别和调试,动作请包含结果值,如“切换为蓝色”</small></header><div id="extensionGscuNamedCommands"></div></section>
<pre id="extensionGscuTcpResult">等待操作</pre> <details class="v2-gscu-result" id="extensionGscuTcpResultDetails"><summary>JSON 调试结果</summary><pre id="extensionGscuTcpResult">等待操作</pre></details>
</section> </section>
</dialog> </dialog>
<div class="v2-extension-toast-stack" id="extensionToastStack" aria-live="polite"></div>`; <div class="v2-extension-toast-stack" id="extensionToastStack" aria-live="polite"></div>`;
@@ -817,7 +815,7 @@
$("b", status).textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE"; $("b", status).textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE";
if (refreshControls) { if (refreshControls) {
renderGscuNamedCommands(); renderGscuNamedCommands();
syncGscuCommandNameFromSlot(); syncGscuCommandNamesFromSlot();
} }
} }
@@ -826,24 +824,143 @@
return [...state.infraredCommands].sort((left, right) => Number(left.slot) - Number(right.slot)); return [...state.infraredCommands].sort((left, right) => Number(left.slot) - Number(right.slot));
} }
function normalizedInfraredNames(value) {
const values = Array.isArray(value)
? value
: String(value || "").split(/[,,、;;|]+/);
return [...new Set(values
.map((item) => String(item || "").trim().slice(0, 48))
.filter(Boolean))].slice(0, 24);
}
function normalizedInfraredDeviceId(value) {
return String(value || "").trim().slice(0, 64);
}
function normalizedInfraredSlotRecord(item) { function normalizedInfraredSlotRecord(item) {
const objectNames = normalizedInfraredNames(item.object_names ?? item.objectNames);
const actionNames = normalizedInfraredNames(item.action_names ?? item.actionNames);
const legacyName = String(item.name || "").trim().slice(0, 48);
return { return {
slot: Number(item.slot), slot: Number(item.slot),
name: String(item.name || "").trim().slice(0, 48), deviceId: normalizedInfraredDeviceId(item.device_id ?? item.deviceId),
name: legacyName || `${objectNames[0] || "未命名对象"} · ${actionNames[0] || "未命名动作"}`,
objectNames,
actionNames: actionNames.length || objectNames.length ? actionNames : normalizedInfraredNames(legacyName),
source: String(item.source || "tcp"), source: String(item.source || "tcp"),
learnedAt: item.learned_at || item.learnedAt || null, learnedAt: item.learned_at || item.learnedAt || null,
updatedAt: item.updated_at || item.updatedAt || null, updatedAt: item.updated_at || item.updatedAt || null,
}; };
} }
function infraredDeviceGroups() {
const groups = new Map();
namedInfraredCommands().forEach((command) => {
const deviceId = normalizedInfraredDeviceId(command.deviceId);
const key = deviceId || "";
if (!groups.has(key)) {
groups.set(key, { deviceId, objectNames: [], commands: [] });
}
const group = groups.get(key);
group.commands.push(command);
group.objectNames = normalizedInfraredNames([
...group.objectNames,
...command.objectNames,
]);
});
return [...groups.values()].sort((left, right) => {
if (!left.deviceId) return 1;
if (!right.deviceId) return -1;
return left.deviceId.localeCompare(right.deviceId);
});
}
function reconcileDevicesAndCapabilitiesFromSlots() {
const configuredKeys = new Set();
infraredDeviceGroups().filter((group) => group.deviceId).forEach((group) => {
let device = state.devices.find((item) => item.id === group.deviceId);
if (!device) {
device = {
id: group.deviceId,
name: group.objectNames[0] || group.deviceId,
type: "other",
aliases: group.objectNames.length ? group.objectNames : [group.deviceId],
toyNumber: null,
cardUid: null,
createdAt: new Date().toISOString(),
source: "infrared_slot_catalog",
};
state.devices.push(device);
} else {
device.name = group.objectNames[0] || device.name || group.deviceId;
device.aliases = group.objectNames.length ? group.objectNames : [device.name];
}
group.commands.forEach((command) => {
const key = `${group.deviceId}\u0000${command.slot}`;
configuredKeys.add(key);
let capability = state.capabilities.find((item) => (
item.deviceId === group.deviceId && Number(item.slot) === Number(command.slot)
));
if (!capability) {
capability = {
id: `ir-slot:${group.deviceId}:${command.slot}`,
deviceId: group.deviceId,
key: `slot_${command.slot}`,
createdAt: new Date().toISOString(),
enabled: true,
};
state.capabilities.push(capability);
}
Object.assign(capability, {
deviceId: group.deviceId,
name: command.actionNames[0] || command.name,
aliases: command.actionNames.length ? command.actionNames : [command.name],
category: "infrared_slot",
risk: capability.risk || "low",
slot: Number(command.slot),
status: "learned",
verified: true,
enabled: capability.enabled !== false,
signalSource: "slot_catalog",
});
});
});
state.capabilities = state.capabilities.filter((capability) => {
if (!["slot_catalog", "backend_catalog"].includes(capability.signalSource)) return true;
return configuredKeys.has(`${capability.deviceId}\u0000${Number(capability.slot)}`);
});
}
function configuredCapabilities(deviceId = null) {
const configuredKeys = new Set(
namedInfraredCommands()
.filter((command) => command.deviceId)
.map((command) => `${command.deviceId}\u0000${Number(command.slot)}`),
);
return state.capabilities.filter((item) => (
(!deviceId || item.deviceId === deviceId)
&& item.status === "learned"
&& item.verified
&& validSlot(item.slot)
&& configuredKeys.has(`${item.deviceId}\u0000${Number(item.slot)}`)
));
}
function replaceInfraredSlotCatalog(items) { function replaceInfraredSlotCatalog(items) {
state.infraredCommands = (Array.isArray(items) ? items : []) state.infraredCommands = (Array.isArray(items) ? items : [])
.filter((item) => validSlot(item?.slot) && String(item?.name || "").trim()) .filter((item) => validSlot(item?.slot) && (
String(item?.name || "").trim()
|| normalizedInfraredNames(item?.object_names ?? item?.objectNames).length
|| normalizedInfraredNames(item?.action_names ?? item?.actionNames).length
))
.map(normalizedInfraredSlotRecord) .map(normalizedInfraredSlotRecord)
.sort((left, right) => left.slot - right.slot); .sort((left, right) => left.slot - right.slot);
reconcileDevicesAndCapabilitiesFromSlots();
saveState(); saveState();
renderGscuNamedCommands(); renderGscuNamedCommands();
syncGscuCommandNameFromSlot(); syncGscuCommandNamesFromSlot();
if ($("#extensionNfcDialog")) renderNfcDialog();
renderUnifiedExtensionView?.();
} }
async function refreshInfraredSlotCatalog({ migrateLegacy = true } = {}) { async function refreshInfraredSlotCatalog({ migrateLegacy = true } = {}) {
@@ -857,7 +974,16 @@
if (missing.length) { if (missing.length) {
await Promise.all(missing.map((item) => api(`gscu/slots/${item.slot}`, { await Promise.all(missing.map((item) => api(`gscu/slots/${item.slot}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ name: item.name, source: item.source || "browser-migration" }), body: JSON.stringify(
item.deviceId && item.objectNames?.length && item.actionNames?.length
? {
device_id: item.deviceId,
object_names: item.objectNames,
action_names: item.actionNames,
source: item.source || "browser-migration",
}
: { name: item.name, source: item.source || "browser-migration" },
),
}))); })));
result = await api("gscu/slots"); result = await api("gscu/slots");
} }
@@ -866,43 +992,74 @@
return namedInfraredCommands(); return namedInfraredCommands();
} }
async function upsertNamedInfraredCommand(slot, name, source = "tcp") { async function upsertNamedInfraredCommand(
slot,
deviceId,
objectNames,
actionNames,
source = "tcp",
) {
const normalizedSlot = Number(slot); const normalizedSlot = Number(slot);
const normalizedName = String(name || "").trim().slice(0, 48); const normalizedDeviceId = normalizedInfraredDeviceId(deviceId);
const normalizedObjects = normalizedInfraredNames(objectNames);
const normalizedActions = normalizedInfraredNames(actionNames);
if (!validSlot(normalizedSlot)) throw new Error("红外槽位范围是 1–248。"); if (!validSlot(normalizedSlot)) throw new Error("红外槽位范围是 1–248。");
if (!normalizedName) throw new Error("学习红外指令前需要填写指令名称。"); if (!normalizedDeviceId) throw new Error("请填写唯一的设备 ID。");
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalizedDeviceId)) {
throw new Error("设备 ID 必须以字母或数字开头,且只能包含字母、数字、点、下划线、冒号或连字符。");
}
if (!normalizedObjects.length) throw new Error("请至少填写一个控制对象名称。");
if (!normalizedActions.length) throw new Error("请至少填写一个执行动作。");
const result = await api(`gscu/slots/${normalizedSlot}`, { const result = await api(`gscu/slots/${normalizedSlot}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ name: normalizedName, source }), body: JSON.stringify({
device_id: normalizedDeviceId,
object_names: normalizedObjects,
action_names: normalizedActions,
source,
}),
}); });
const value = normalizedInfraredSlotRecord(result.slot); const value = normalizedInfraredSlotRecord(result.slot);
const existing = state.infraredCommands.find((item) => item.slot === normalizedSlot); const existing = state.infraredCommands.find((item) => item.slot === normalizedSlot);
if (existing) Object.assign(existing, value); if (existing) Object.assign(existing, value);
else state.infraredCommands.push(value); else state.infraredCommands.push(value);
reconcileDevicesAndCapabilitiesFromSlots();
const boundDevice = state.devices.find((item) => item.id === value.deviceId);
saveState(); saveState();
renderGscuNamedCommands(); renderGscuNamedCommands();
if (boundDevice) {
renderAll();
await syncPeripheralCatalog(boundDevice);
}
return value; return value;
} }
function renderGscuNamedCommands() { function renderGscuNamedCommands() {
const container = $("#extensionGscuNamedCommands"); const container = $("#extensionGscuNamedCommands");
if (!container) return; if (!container) return;
const commands = namedInfraredCommands(); const groups = infraredDeviceGroups();
container.innerHTML = commands.length container.innerHTML = groups.length
? commands.map((command) => `<article data-gscu-command-slot="${command.slot}"><header><span>红外槽位</span><b>IR ${String(command.slot).padStart(3, "0")}</b></header><label><span>信号名称</span><input maxlength="48" value="${escapeHtml(command.name)}" aria-label="IR ${command.slot} 信号名称" /></label><footer><button type="button" data-gscu-named-emit="${command.slot}">发射</button><button type="button" data-gscu-named-save="${command.slot}">保存名称</button><button class="danger" type="button" data-gscu-named-remove="${command.slot}">删除本地记录</button></footer></article>`).join("") ? groups.map((group) => `<section class="v2-gscu-device-group" data-device-id="${escapeHtml(group.deviceId)}"><header><div><small>${group.deviceId ? "DEVICE ID" : "UNASSIGNED"}</small><strong>${escapeHtml(group.deviceId || "未分配设备 ID")}</strong><span>${escapeHtml(group.objectNames.join("、") || "尚无控制对象描述")}</span></div><b>${group.commands.length} 条命令</b></header><div class="v2-gscu-device-command-grid">${group.commands.map((command) => `<article data-gscu-command-slot="${command.slot}"><header><span>红外槽位</span><b>IR ${String(command.slot).padStart(3, "0")}</b></header><label><span>设备 ID</span><input data-gscu-device-id maxlength="64" value="${escapeHtml(command.deviceId)}" placeholder="唯一设备 ID" aria-label="IR ${command.slot} 设备 ID" /></label><label><span>控制对象名称 / 别名</span><input data-gscu-object-names maxlength="240" value="${escapeHtml(command.objectNames.join("、"))}" placeholder="用逗号分隔多个名称" aria-label="IR ${command.slot} 控制对象名称" /></label><label><span>执行动作 / 别名</span><input data-gscu-action-names maxlength="240" value="${escapeHtml(command.actionNames.join("、"))}" placeholder="颜色等动作需包含结果值" aria-label="IR ${command.slot} 执行动作" /></label><footer><button type="button" data-gscu-named-emit="${command.slot}">发射</button><button type="button" data-gscu-named-save="${command.slot}">保存设置</button><button class="danger" type="button" data-gscu-named-remove="${command.slot}">删除本地记录</button></footer></article>`).join("")}</div></section>`).join("")
: "<p>学习成功后将在这里显示编号与指令名称。</p>"; : "<p>学习成功后将在这里按设备 ID 显示槽位、控制对象和执行动作。</p>";
} }
function syncGscuCommandNameFromSlot() { function syncGscuCommandNamesFromSlot() {
const slot = Number($("#extensionGscuSlot")?.value); const slot = Number($("#extensionGscuSlot")?.value);
const command = namedInfraredCommands().find((item) => item.slot === slot); const command = namedInfraredCommands().find((item) => item.slot === slot);
if ($("#extensionGscuCommandName")) { if ($("#extensionGscuDeviceId")) {
$("#extensionGscuCommandName").value = command?.name || ""; $("#extensionGscuDeviceId").value = command?.deviceId || "";
}
if ($("#extensionGscuObjectNames")) {
$("#extensionGscuObjectNames").value = command?.objectNames.join("、") || "";
}
if ($("#extensionGscuActionNames")) {
$("#extensionGscuActionNames").value = command?.actionNames.join("、") || "";
} }
} }
function openGscuTcpDialog(health = healthState || {}) { function openGscuTcpDialog(health = healthState || {}) {
renderGscuTcpDialog(health); renderGscuTcpDialog(health);
$("#extensionGscuTcpResultDetails")?.removeAttribute("open");
openDialog("extensionGscuTcpDialog"); openDialog("extensionGscuTcpDialog");
void refreshInfraredSlotCatalog().catch((error) => { void refreshInfraredSlotCatalog().catch((error) => {
showToast(`读取本地红外槽位数据库失败:${error.message}`, "error"); showToast(`读取本地红外槽位数据库失败:${error.message}`, "error");
@@ -945,7 +1102,9 @@
async function runGscuTcpAction(button) { async function runGscuTcpAction(button) {
const action = button.dataset.gscuAction; const action = button.dataset.gscuAction;
const slot = Number($("#extensionGscuSlot").value); const slot = Number($("#extensionGscuSlot").value);
const commandName = $("#extensionGscuCommandName").value.trim(); const deviceId = normalizedInfraredDeviceId($("#extensionGscuDeviceId").value);
const objectNames = normalizedInfraredNames($("#extensionGscuObjectNames").value);
const actionNames = normalizedInfraredNames($("#extensionGscuActionNames").value);
const payload = {}; const payload = {};
if (["learn", "emit", "read", "write"].includes(action)) payload.slot = slot; if (["learn", "emit", "read", "write"].includes(action)) payload.slot = slot;
if (action === "write") payload.ir_data = $("#extensionGscuIrData").value.trim(); if (action === "write") payload.ir_data = $("#extensionGscuIrData").value.trim();
@@ -968,9 +1127,19 @@
payload.confirm = "ERASE_ALL"; payload.confirm = "ERASE_ALL";
} }
if (action === "reset") payload.confirm = confirmation; if (action === "reset") payload.confirm = confirmation;
if (action === "learn" && !commandName) { if (action === "learn" && !deviceId) {
showToast("请输入该红外编号对应的指令名称。", "error"); showToast("请输入该红外槽位对应的唯一设备 ID。", "error");
$("#extensionGscuCommandName").focus(); $("#extensionGscuDeviceId").focus();
return;
}
if (action === "learn" && !objectNames.length) {
showToast("请输入该红外槽位对应的控制对象名称。", "error");
$("#extensionGscuObjectNames").focus();
return;
}
if (action === "learn" && !actionNames.length) {
showToast("请输入该红外槽位对应的执行动作。", "error");
$("#extensionGscuActionNames").focus();
return; return;
} }
button.disabled = true; button.disabled = true;
@@ -1006,7 +1175,7 @@
: "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。", : "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。",
); );
} }
await upsertNamedInfraredCommand(slot, commandName, "tcp"); await upsertNamedInfraredCommand(slot, deviceId, objectNames, actionNames, "tcp");
result = { result = {
...result, ...result,
learning_capture_confirmed: true, learning_capture_confirmed: true,
@@ -1051,7 +1220,7 @@
if (!dialog || dialog.dataset.bound === "true") return; if (!dialog || dialog.dataset.bound === "true") return;
dialog.dataset.bound = "true"; dialog.dataset.bound = "true";
$("#extensionGscuTcpForm").addEventListener("submit", configureGscuTcp); $("#extensionGscuTcpForm").addEventListener("submit", configureGscuTcp);
$("#extensionGscuSlot").addEventListener("change", syncGscuCommandNameFromSlot); $("#extensionGscuSlot").addEventListener("change", syncGscuCommandNamesFromSlot);
dialog.addEventListener("click", (event) => { dialog.addEventListener("click", (event) => {
if (event.target.closest('[data-close-extension-dialog="extensionGscuTcpDialog"]')) { if (event.target.closest('[data-close-extension-dialog="extensionGscuTcpDialog"]')) {
closeDialog("extensionGscuTcpDialog"); closeDialog("extensionGscuTcpDialog");
@@ -1068,7 +1237,9 @@
const command = namedInfraredCommands().find( const command = namedInfraredCommands().find(
(item) => item.slot === Number(emit.dataset.gscuNamedEmit), (item) => item.slot === Number(emit.dataset.gscuNamedEmit),
); );
$("#extensionGscuCommandName").value = command?.name || ""; $("#extensionGscuDeviceId").value = command?.deviceId || "";
$("#extensionGscuObjectNames").value = command?.objectNames.join("、") || "";
$("#extensionGscuActionNames").value = command?.actionNames.join("、") || "";
const emitButton = $("[data-gscu-action=\"emit\"]", dialog); const emitButton = $("[data-gscu-action=\"emit\"]", dialog);
if (emitButton) void runGscuTcpAction(emitButton); if (emitButton) void runGscuTcpAction(emitButton);
return; return;
@@ -1079,10 +1250,12 @@
save.disabled = true; save.disabled = true;
void upsertNamedInfraredCommand( void upsertNamedInfraredCommand(
Number(save.dataset.gscuNamedSave), Number(save.dataset.gscuNamedSave),
$("input", card).value, $("[data-gscu-device-id]", card).value,
$("[data-gscu-object-names]", card).value,
$("[data-gscu-action-names]", card).value,
"tcp", "tcp",
).then((value) => { ).then((value) => {
showToast(`IR ${value.slot} 已保存为“${value.name}”。`, "success"); showToast(`IR ${value.slot} 的控制对象与执行动作已保存。`, "success");
}).catch((error) => { }).catch((error) => {
showToast(error.message, "error"); showToast(error.message, "error");
}).finally(() => { save.disabled = false; }); }).finally(() => { save.disabled = false; });
@@ -1095,9 +1268,15 @@
if (!window.confirm(`确认删除“${command?.name || `IR ${slot}`}”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。`)) return; if (!window.confirm(`确认删除“${command?.name || `IR ${slot}`}”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。`)) return;
remove.disabled = true; remove.disabled = true;
void api(`gscu/slots/${slot}`, { method: "DELETE" }).then((result) => { void api(`gscu/slots/${slot}`, { method: "DELETE" }).then((result) => {
const affectedDeviceId = command?.deviceId;
state.infraredCommands = namedInfraredCommands().filter((item) => item.slot !== slot); state.infraredCommands = namedInfraredCommands().filter((item) => item.slot !== slot);
reconcileDevicesAndCapabilitiesFromSlots();
saveState(); saveState();
renderGscuNamedCommands(); renderGscuNamedCommands();
renderAll();
void syncPeripheralCatalog(
state.devices.find((item) => item.id === affectedDeviceId) || null,
);
showToast(result.message || `IR ${slot} 的本地记录已删除。`, "success"); showToast(result.message || `IR ${slot} 的本地记录已删除。`, "success");
}).catch((error) => { }).catch((error) => {
remove.disabled = false; remove.disabled = false;
@@ -1660,7 +1839,8 @@
const capability = state.capabilities.find( const capability = state.capabilities.find(
(item) => item.id === learning.draft.capabilityId, (item) => item.id === learning.draft.capabilityId,
); );
if (!capability) return; const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
const button = $("#extensionConfirmLearn"); const button = $("#extensionConfirmLearn");
button.disabled = true; button.disabled = true;
const relearning = Boolean(learning.draft.relearning); const relearning = Boolean(learning.draft.relearning);
@@ -1668,7 +1848,9 @@
try { try {
await upsertNamedInfraredCommand( await upsertNamedInfraredCommand(
Number(learning.draft.slot), Number(learning.draft.slot),
capability.name, device.id,
[device.name, ...(device.aliases || [])],
[capability.name, ...learning.draft.aliases],
"capability", "capability",
); );
} catch (error) { } catch (error) {
@@ -1715,28 +1897,123 @@
}); });
saveState(); saveState();
renderAll(); renderAll();
void syncPeripheralCatalog();
showToast(`“${capability.name}”已恢复为待学习。`); showToast(`“${capability.name}”已恢复为待学习。`);
} }
function peripheralCatalog() { function peripheralCatalog() {
return learnedCapabilities().map((capability) => { return configuredCapabilities().map((capability) => {
const device = state.devices.find((item) => item.id === capability.deviceId); const device = state.devices.find((item) => item.id === capability.deviceId);
const type = TOY_TYPES[device?.type] || TOY_TYPES.other; const type = TOY_TYPES[device?.type] || TOY_TYPES.other;
const slotRecord = namedInfraredCommands().find(
(item) => item.deviceId === capability.deviceId
&& Number(item.slot) === Number(capability.slot),
);
const objectNames = slotRecord?.objectNames?.length
? slotRecord.objectNames
: normalizedInfraredNames([device?.name, ...(device?.aliases || [])]);
const actionNames = slotRecord?.actionNames?.length
? slotRecord.actionNames
: normalizedInfraredNames([capability.name, ...(capability.aliases || [])]);
return { return {
id: capability.id, id: capability.id,
status: "learned", status: "learned",
device_id: device?.id, device_id: device?.id,
device_name: device?.name, device_name: device?.name,
device_aliases: device?.aliases || [],
object_names: objectNames,
device_type: device?.type, device_type: device?.type,
card_uid: device?.cardUid || null, card_uid: device?.cardUid || null,
toy_number: device?.toyNumber || null, toy_number: device?.toyNumber || null,
type_label: type.label, type_label: type.label,
action: capability.name, action: capability.name,
aliases: capability.aliases || [], aliases: capability.aliases || [],
action_names: actionNames,
risk: capability.risk || "low", risk: capability.risk || "low",
slot: capability.slot, slot: capability.slot,
enabled: capability.enabled !== false,
transport: "gscu_tcp",
}; };
}).filter((item) => item.device_id); }).filter((item) => item.device_id && item.card_uid && validSlot(item.slot));
}
async function hydratePeripheralCatalog() {
const payload = await api("peripherals");
const catalog = Array.isArray(payload.catalog) ? payload.catalog : [];
if (!catalog.length) return 0;
const restoredDeviceIds = new Set();
catalog.forEach((item) => {
if (!item || typeof item !== "object" || !item.id || !validSlot(item.slot)) return;
const objectNames = normalizedInfraredNames(item.object_names);
const actionNames = normalizedInfraredNames(item.action_names);
let device = state.devices.find((candidate) => candidate.id === item.device_id);
if (!device) {
device = {
id: item.device_id,
name: objectNames[0] || item.device_name,
type: TOY_TYPES[item.device_type] ? item.device_type : "other",
aliases: objectNames.length
? objectNames
: Array.isArray(item.device_aliases)
? item.device_aliases
: [item.device_name],
toyNumber: item.toy_number || nextToyNumber(),
cardUid: normalizeCardUid(item.card_uid),
createdAt: new Date().toISOString(),
};
state.devices.push(device);
restoredDeviceIds.add(device.id);
} else {
device.name = objectNames[0] || item.device_name || device.name;
device.aliases = objectNames.length
? objectNames
: Array.isArray(item.device_aliases)
? item.device_aliases
: device.aliases || [device.name];
device.cardUid = normalizeCardUid(item.card_uid) || device.cardUid;
}
let capability = state.capabilities.find((candidate) => candidate.id === item.id);
if (!capability) {
capability = {
id: item.id,
deviceId: device.id,
key: "restored",
name: actionNames[0] || item.action,
aliases: actionNames.length
? actionNames
: Array.isArray(item.aliases) ? item.aliases : [item.action],
category: "restored",
risk: item.risk || "low",
visual: null,
createdAt: new Date().toISOString(),
};
state.capabilities.push(capability);
}
Object.assign(capability, {
deviceId: device.id,
name: actionNames[0] || item.action,
aliases: actionNames.length
? actionNames
: Array.isArray(item.aliases) ? item.aliases : capability.aliases || [],
risk: item.risk || "low",
slot: Number(item.slot),
status: "learned",
verified: true,
enabled: item.enabled !== false,
signalSource: "backend_catalog",
});
});
restoredDeviceIds.forEach((deviceId) => {
const device = state.devices.find((item) => item.id === deviceId);
if (!device) return;
const knownNames = new Set(deviceCapabilities(deviceId).map((item) => item.name));
seedPresets(device).forEach((capability) => {
if (!knownNames.has(capability.name)) state.capabilities.push(capability);
});
});
saveState();
renderAll();
return catalog.length;
} }
function vehicleCatalog() { function vehicleCatalog() {
@@ -3046,6 +3323,7 @@
const reader = nfcSnapshot.reader || {}; const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card; const card = nfcSnapshot.card;
const linkedDevice = linkedDeviceForCard(card?.uid); const linkedDevice = linkedDeviceForCard(card?.uid);
const deviceGroups = infraredDeviceGroups().filter((group) => group.deviceId);
status.textContent = nfcReaderNote(reader); status.textContent = nfcReaderNote(reader);
badge.textContent = reader.online ? "READER READY" : "READER OFFLINE"; badge.textContent = reader.online ? "READER READY" : "READER OFFLINE";
badge.dataset.state = reader.online ? "online" : "offline"; badge.dataset.state = reader.online ? "online" : "offline";
@@ -3055,37 +3333,25 @@
body.innerHTML = `<div class="v2-nfc-waiting offline"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>NFC READER</small><strong>读卡器尚未就绪</strong><p>${escapeHtml(nfcReaderNote(reader))}</p></div></div>`; body.innerHTML = `<div class="v2-nfc-waiting offline"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>NFC READER</small><strong>读卡器尚未就绪</strong><p>${escapeHtml(nfcReaderNote(reader))}</p></div></div>`;
} else if (!card?.uid) { } else if (!card?.uid) {
stage.dataset.state = "waiting"; stage.dataset.state = "waiting";
body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>WAITING FOR TOY</small><strong>请将玩具 NFC 靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后会自动显示该玩具及其动作能力。</p></div></div>`; body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>WAITING FOR NFC</small><strong>请将 NFC 卡靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到一个已配置的设备 ID。</p></div></div>`;
} else if (!linkedDevice) { } else if (!linkedDevice) {
const suggestedNumber = nextToyNumber();
stage.dataset.state = "new-card"; stage.dataset.state = "new-card";
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到新玩具卡片</small><strong>${escapeHtml(card.uid)}</strong><p>这张 NFC 尚未绑定,可新建玩具档案或关联到已有玩具。</p></div></div> body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到未绑定 NFC</small><strong>${escapeHtml(card.uid)}</strong><p>请选择设备 ID;控制对象名称仅作为调试辅助说明。</p></div></div>
<div class="v2-nfc-create-grid"> ${deviceGroups.length ? `<div class="v2-nfc-existing"><span>绑定对象(设备 ID)</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${deviceGroups.map((group) => `<option value="${escapeHtml(group.deviceId)}">${escapeHtml(group.deviceId)} — ${escapeHtml(group.objectNames.join(" / ") || "无对象描述")} — ${group.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>关联设备 ID</button></div><small>关联后,该设备的全部命令默认允许执行,可再逐项取消。</small></div>` : `<div class="v2-nfc-no-devices"><i data-lucide="info"></i><div><strong>暂无可绑定的设备 ID</strong><span>请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。</span></div></div>`}`;
<label><span>玩具名称</span><input id="extensionNfcToyName" maxlength="48" value="玩具 ${suggestedNumber}" /></label>
<label><span>玩具类别</span><select id="extensionNfcToyType">${Object.entries(TOY_TYPES).map(([key, type]) => `<option value="${escapeHtml(key)}">${escapeHtml(type.label)}</option>`).join("")}</select></label>
</div>
<div class="v2-nfc-primary-actions"><button class="v2-extension-primary" type="button" data-nfc-create-and-learn><i data-lucide="radio-tower"></i>建立档案并开始学习</button></div>
${state.devices.length ? `<div class="v2-nfc-existing"><span>或绑定到已有玩具</span><div><select id="extensionNfcExistingDevice">${state.devices.map((device) => `<option value="${escapeHtml(device.id)}">${device.toyNumber ? `#${escapeHtml(device.toyNumber)} · ` : ""}${escapeHtml(device.name)}</option>`).join("")}</select><button type="button" data-nfc-bind-existing>绑定</button></div></div>` : ""}`;
} else { } else {
const type = TOY_TYPES[linkedDevice.type] || TOY_TYPES.other; const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
const capabilities = deviceCapabilities(linkedDevice.id); const capabilities = configuredCapabilities(linkedDevice.id);
const learned = learnedCapabilities(linkedDevice.id); const enabledCount = capabilities.filter((capability) => capability.enabled !== false).length;
const firstPending = capabilities.find((capability) => capability.status !== "learned"); const allEnabled = capabilities.length > 0 && enabledCount === capabilities.length;
stage.dataset.state = "recognized"; stage.dataset.state = "recognized";
body.innerHTML = `<div class="v2-nfc-recognized-heading"> body.innerHTML = `<div class="v2-nfc-recognized-heading">
<span data-extension-type="${escapeHtml(linkedDevice.type || "other")}"><i data-lucide="${type.icon}"></i></span> <span data-extension-type="other"><i data-lucide="cpu"></i></span>
<div><small>已识别并绑定 · ${escapeHtml(card.uid)}</small><strong>${linkedDevice.toyNumber ? `#${escapeHtml(linkedDevice.toyNumber)} · ` : ""}${escapeHtml(linkedDevice.name)}</strong><p>${escapeHtml(type.label)} · ${learned.length} 项动作可执行</p></div> <div><small>NFC UID · ${escapeHtml(card.uid)}</small><strong>${escapeHtml(linkedDevice.id)}</strong><p>控制对象:${escapeHtml(group?.objectNames.join("、") || linkedDevice.name || "无辅助描述")} · ${enabledCount}/${capabilities.length} 条命令已授权</p></div>
<i data-lucide="badge-check"></i> <i data-lucide="badge-check"></i>
</div> </div>
<div class="v2-nfc-capability-heading"><div><small>TOY ACTION CAPABILITIES</small><strong>玩具动作能力</strong></div><span>${learned.length}/${capabilities.length} 已学习</span></div> <div class="v2-nfc-capability-heading"><div><small>COMMAND AUTHORIZATION</small><strong>允许此 NFC 执行的命令</strong></div><span>${enabledCount}/${capabilities.length} 已选择</span></div>
<div class="v2-nfc-capability-grid">${capabilities.map((capability) => { ${capabilities.length ? `<label class="v2-nfc-select-all"><input type="checkbox" data-nfc-select-all ${allEnabled ? "checked" : ""} /><span><strong>全选</strong><small>统一允许或禁止当前设备 ID 下的全部命令</small></span></label><div class="v2-nfc-command-list">${capabilities.map((capability) => `<label class="v2-nfc-command-option"><input type="checkbox" data-nfc-capability-enabled="${escapeHtml(capability.id)}" ${capability.enabled !== false ? "checked" : ""} /><span><strong>${escapeHtml(capability.name)}</strong><small>IR ${String(capability.slot).padStart(3, "0")} · ${escapeHtml((capability.aliases || []).join("、"))}</small></span></label>`).join("")}</div>` : `<div class="v2-nfc-no-devices"><i data-lucide="triangle-alert"></i><div><strong>该设备 ID 暂无有效命令</strong><span>槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。</span></div></div>`}
const isLearned = learned.some((item) => item.id === capability.id); <div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
if (!isLearned) {
return `<div class="v2-nfc-capability-card pending"><button class="v2-nfc-capability-primary" type="button" data-nfc-learn-capability="${escapeHtml(capability.id)}"><span>${escapeHtml(capability.name)}</span><small>待学习 · 点击录入</small><i data-lucide="plus"></i></button></div>`;
}
return `<div class="v2-nfc-capability-card learned"><button class="v2-nfc-capability-primary" type="button" data-nfc-run-capability="${escapeHtml(capability.id)}"><span>${escapeHtml(capability.name)}</span><small>IR ${String(capability.slot).padStart(3, "0")} · 点击发射</small><i data-lucide="send"></i></button><button class="v2-nfc-relearn" type="button" data-nfc-relearn-capability="${escapeHtml(capability.id)}" title="覆盖原槽位并重新学习“${escapeHtml(capability.name)}”"><i data-lucide="refresh-cw"></i><span>重新学习</span></button></div>`;
}).join("")}</div>
<div class="v2-nfc-recognized-actions"><button class="v2-extension-primary" type="button" data-nfc-continue-learning ${firstPending ? "" : "disabled"}><i data-lucide="radio-tower"></i>${firstPending ? "继续学习红外" : "动作已全部学习"}</button><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
} }
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } }); window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
} }
@@ -3095,12 +3361,12 @@
const dialog = document.createElement("dialog"); const dialog = document.createElement("dialog");
dialog.className = "v2-extension-dialog v2-nfc-dialog"; dialog.className = "v2-extension-dialog v2-nfc-dialog";
dialog.id = "extensionNfcDialog"; dialog.id = "extensionNfcDialog";
dialog.innerHTML = `<header><div><span class="section-kicker">NFC · INFRARED LEARNING</span><h2>感应并学习玩具</h2><p>刷卡识别玩具,再通过现有 GSCU1B 链路学习和验证红外动作。</p></div><button type="button" data-close-nfc-dialog aria-label="关闭"><i data-lucide="x"></i></button></header> dialog.innerHTML = `<header><div><span class="section-kicker">NFC · DEVICE AUTHORIZATION</span><h2>NFC 设备授权</h2><p>将 NFC UID 绑定到设备 ID,并限制该卡片可执行的红外命令。</p></div><button type="button" data-close-nfc-dialog aria-label="关闭"><i data-lucide="x"></i></button></header>
<section class="v2-nfc-stage" id="extensionNfcStage" data-state="waiting"> <section class="v2-nfc-stage" id="extensionNfcStage" data-state="waiting">
<header><span><i data-lucide="scan-line"></i></span><div><small>靠近读卡器后</small><h3>玩具 NFC 感应区</h3><p id="extensionNfcReaderStatus">正在检查读卡器</p></div><b id="extensionNfcReaderBadge" data-state="offline">READER CHECK</b></header> <header><span><i data-lucide="scan-line"></i></span><div><small>靠近读卡器后</small><h3>NFC 设备绑定</h3><p id="extensionNfcReaderStatus">正在检查读卡器</p></div><b id="extensionNfcReaderBadge" data-state="offline">READER CHECK</b></header>
<div class="v2-nfc-body" id="extensionNfcBody"></div> <div class="v2-nfc-body" id="extensionNfcBody"></div>
</section> </section>
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>NFC UID 与玩具动作绑定保存在当前浏览器;红外波形保存在 GSCU1B 物理槽位。</span></footer>`; <footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>NFC UID、设备 ID 与命令授权同步到本机白名单;红外波形仍保存在 GSCU1B 物理槽位。</span></footer>`;
document.body.append(dialog); document.body.append(dialog);
} }
@@ -3131,43 +3397,18 @@
void syncPeripheralCatalog(device); void syncPeripheralCatalog(device);
} }
function createToyFromNfc() {
const cardUid = nfcSnapshot.card?.uid;
const name = $("#extensionNfcToyName")?.value.trim();
const typeKey = $("#extensionNfcToyType")?.value;
if (!cardUid) return showToast("请先将玩具 NFC 靠近读卡器。", "error");
if (!name) return showToast("请输入玩具名称。", "error");
if (linkedDeviceForCard(cardUid)) return showToast("这张 NFC 已绑定玩具。", "error");
if (state.devices.some((device) => device.name === name)) {
return showToast("已经存在同名玩具,请改用其他名称。", "error");
}
const device = {
id: uid("toy"),
name,
type: TOY_TYPES[typeKey] ? typeKey : "other",
aliases: [name],
toyNumber: nextToyNumber(),
cardUid: normalizeCardUid(cardUid),
createdAt: new Date().toISOString(),
};
const capabilities = seedPresets(device);
state.devices.push(device);
state.capabilities.push(...capabilities);
state.selectedDeviceByType[device.type] = device.id;
saveState();
renderAll();
void syncPeripheralCatalog(device);
showToast(`已识别“${device.name}”,请选择遥控动作开始学习。`, "success");
if (capabilities[0]) openLearn(capabilities[0].id);
}
function bindCurrentNfcToExisting() { function bindCurrentNfcToExisting() {
const deviceId = $("#extensionNfcExistingDevice")?.value; const deviceId = $("#extensionNfcExistingDevice")?.value;
reconcileDevicesAndCapabilitiesFromSlots();
const device = state.devices.find((item) => item.id === deviceId); const device = state.devices.find((item) => item.id === deviceId);
const cardUid = nfcSnapshot.card?.uid; const cardUid = nfcSnapshot.card?.uid;
if (!device || !cardUid) return; if (!device || !cardUid) return;
configuredCapabilities(device.id).forEach((capability) => {
capability.enabled = true;
});
bindCardToDevice(device, cardUid); bindCardToDevice(device, cardUid);
showToast(`NFC 已绑定到“${device.name}”。`, "success"); renderNfcDialog();
showToast(`NFC 已关联设备 ID“${device.id}”,全部命令已默认选中。`, "success");
} }
function unbindCurrentNfc() { function unbindCurrentNfc() {
@@ -3177,36 +3418,33 @@
saveState(); saveState();
renderAll(); renderAll();
void syncPeripheralCatalog(device); void syncPeripheralCatalog(device);
showToast(`已解除“${device.name}”的 NFC 绑定。`); renderNfcDialog();
showToast(`已解除设备 ID“${device.id}”的 NFC 绑定。`);
} }
function continueNfcLearning(capabilityId = null) { function updateNfcCapabilityAuthorization(capabilityId, enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid); const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
const capability = capabilityId const capability = configuredCapabilities(device?.id).find(
? state.capabilities.find((item) => item.id === capabilityId) (item) => item.id === capabilityId,
: deviceCapabilities(device?.id).find((item) => item.status !== "learned"); );
if (!device || !capability) { if (!device || !capability) return;
showToast("该玩具没有待学习动作。", "success"); capability.enabled = enabled;
return;
}
state.selectedDeviceByType[device.type] = device.id;
saveState(); saveState();
openLearn(capability.id); renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
} }
async function emitNfcCapability(capabilityId) { function updateAllNfcCapabilityAuthorizations(enabled) {
const capability = learnedCapabilities().find((item) => item.id === capabilityId); const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
const device = state.devices.find((item) => item.id === capability?.deviceId); if (!device) return;
if (!capability || !device) return; configuredCapabilities(device.id).forEach((capability) => {
if (capability.risk === "high" && !window.confirm( capability.enabled = enabled;
`“${capability.name}”可能让玩具移动,请确认周围安全。`, });
)) return; saveState();
try { renderAll();
await sendGatewayCommand("emit", { slot: capability.slot }); renderNfcDialog();
showToast(`已向“${device.name}”发射“${capability.name}”。`, "success"); void syncPeripheralCatalog(device);
} catch (error) {
showToast(error.message, "error");
}
} }
function applyNfcSnapshot(value, { announce = true } = {}) { function applyNfcSnapshot(value, { announce = true } = {}) {
@@ -3232,10 +3470,10 @@
if (device) { if (device) {
state.selectedDeviceByType[device.type] = device.id; state.selectedDeviceByType[device.type] = device.id;
saveState(); saveState();
if (announce) showToast(`已识别玩具“${device.name}”。`, "success"); if (announce) showToast(`已识别设备 ID“${device.id}”。`, "success");
void syncPeripheralCatalog(device); void syncPeripheralCatalog(device);
} else if (announce) { } else if (announce) {
showToast("检测到新的玩具 NFC,请建立或绑定玩具档案。", "success"); showToast("检测到未绑定 NFC,请选择一个设备 ID。", "success");
} }
} }
renderNfcDialog(); renderNfcDialog();
@@ -3275,23 +3513,27 @@
function bindUnifiedNfcEvents(mount) { function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => { mount.addEventListener("click", (event) => {
if (event.target.closest("[data-open-nfc-learning]")) openNfcDialog(); if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog(); if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
}); });
bindGscuTcpDialogEvents(); bindGscuTcpDialogEvents();
const dialog = $("#extensionNfcDialog"); const dialog = $("#extensionNfcDialog");
dialog.addEventListener("click", (event) => { dialog.addEventListener("click", (event) => {
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog"); if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
if (event.target.closest("[data-nfc-create-and-learn]")) createToyFromNfc();
if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting(); if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting();
if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc(); if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc();
const relearn = event.target.closest("[data-nfc-relearn-capability]"); });
if (relearn) continueNfcLearning(relearn.dataset.nfcRelearnCapability); dialog.addEventListener("change", (event) => {
const learn = event.target.closest("[data-nfc-learn-capability]"); const capability = event.target.closest("[data-nfc-capability-enabled]");
if (learn) continueNfcLearning(learn.dataset.nfcLearnCapability); if (capability) {
if (event.target.closest("[data-nfc-continue-learning]")) continueNfcLearning(); updateNfcCapabilityAuthorization(
const run = event.target.closest("[data-nfc-run-capability]"); capability.dataset.nfcCapabilityEnabled,
if (run) void emitNfcCapability(run.dataset.nfcRunCapability); capability.checked,
);
return;
}
const selectAll = event.target.closest("[data-nfc-select-all]");
if (selectAll) updateAllNfcCapabilityAuthorizations(selectAll.checked);
}); });
$("#extensionContinueLearn").addEventListener("click", () => { $("#extensionContinueLearn").addEventListener("click", () => {
try { try {
@@ -3370,6 +3612,7 @@
? mergedCatalog.filter((item) => ( ? mergedCatalog.filter((item) => (
normalizeCardUid(item.card_uid) === sensedCardUid normalizeCardUid(item.card_uid) === sensedCardUid
&& item.status === "learned" && item.status === "learned"
&& item.enabled !== false
)) ))
: []; : [];
const grouped = new Map(); const grouped = new Map();
@@ -3387,12 +3630,14 @@
const devices = [...grouped.values()]; const devices = [...grouped.values()];
const lastAction = snapshot?.extension?.last_action; const lastAction = snapshot?.extension?.last_action;
const lastResult = snapshot?.extension?.result; const lastResult = snapshot?.extension?.result;
const nfcCapabilities = nfcDevice ? learnedCapabilities(nfcDevice.id) : []; const nfcCapabilities = nfcDevice
? configuredCapabilities(nfcDevice.id).filter((item) => item.enabled !== false)
: [];
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true; const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
const statusLabel = devices.length const statusLabel = devices.length
? "已感应 NFC 外设" ? "已感应 NFC 外设"
: sensedCardUid : sensedCardUid
? "当前 NFC 暂无已学习能力" ? "当前 NFC 暂无已授权命令"
: nfcReader.online : nfcReader.online
? "等待 NFC 外设靠近" ? "等待 NFC 外设靠近"
: "NFC 读卡器离线"; : "NFC 读卡器离线";
@@ -3405,7 +3650,7 @@
: "红外链路检查中"; : "红外链路检查中";
const gatewayNote = physicalConnected const gatewayNote = physicalConnected
? `${health.peer || "GSCU1B"} · 红外学习与发射链路已就绪` ? `${health.peer || "GSCU1B"} · 红外学习与发射链路已就绪`
: health.message || "红外学习器接入后可学习并发射玩具动作"; : health.message || "红外学习器接入后可学习并发射设备动作";
const visibleLastAction = devices.length ? lastAction : null; const visibleLastAction = devices.length ? lastAction : null;
const nfcState = !nfcReader.online const nfcState = !nfcReader.online
? "offline" ? "offline"
@@ -3415,28 +3660,28 @@
? "new-card" ? "new-card"
: "waiting"; : "waiting";
const nfcTitle = nfcDevice const nfcTitle = nfcDevice
? `${nfcDevice.toyNumber ? `#${nfcDevice.toyNumber} · ` : ""}${nfcDevice.name}` ? nfcDevice.id
: nfcCard?.uid : nfcCard?.uid
? "检测到新的玩具 NFC" ? "检测到未绑定 NFC"
: nfcReader.online : nfcReader.online
? "等待玩具靠近" ? "等待 NFC 卡靠近"
: "NFC 读卡器未连接"; : "NFC 读卡器未连接";
const nfcNote = nfcDevice const nfcNote = nfcDevice
? `${nfcCapabilities.length} 项已学习动作 · ${nfcCard.uid}` ? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
: nfcCard?.uid : nfcCard?.uid
? `${nfcCard.uid} · 点击建立或绑定玩具档案` ? `${nfcCard.uid} · 点击关联设备 ID`
: nfcReaderNote(nfcReader); : nfcReaderNote(nfcReader);
mount.dataset.hasDevices = String(devices.length > 0); mount.dataset.hasDevices = String(devices.length > 0);
mount.innerHTML = ` mount.innerHTML = `
<header class="v2-extension-sync-heading"> <header class="v2-extension-sync-heading">
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3><button class="v2-extension-nfc-launch" type="button" data-open-nfc-learning><i data-lucide="scan-line"></i><span>感应玩具 NFC</span></button></div> <div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3><button class="v2-extension-nfc-launch" type="button" data-open-nfc-binding><i data-lucide="scan-line"></i><span>NFC 设备绑定</span></button></div>
<span data-state="${devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span> <span data-state="${devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
<button type="button" data-panel-toggle="extensions" aria-label="${panelCollapsed ? "展开" : "收起"}外设与扩展能力"><i data-lucide="panel-right-close"></i><span data-panel-action-label>${panelCollapsed ? "展开" : "收起"}</span></button> <button type="button" data-panel-toggle="extensions" aria-label="${panelCollapsed ? "展开" : "收起"}外设与扩展能力"><i data-lucide="panel-right-close"></i><span data-panel-action-label>${panelCollapsed ? "展开" : "收起"}</span></button>
</header> </header>
<section class="v2-nfc-inline" data-state="${nfcState}"> <section class="v2-nfc-inline" data-state="${nfcState}">
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span> <span class="v2-nfc-inline-icon"><i data-lucide="${nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
<div><small>靠近读卡器后</small><strong>${escapeHtml(nfcTitle)}</strong><p>${escapeHtml(nfcNote)}</p>${nfcCapabilities.length ? `<div>${nfcCapabilities.slice(0, 6).map((capability) => `<i>${escapeHtml(capability.name)}</i>`).join("")}</div>` : ""}</div> <div><small>靠近读卡器后</small><strong>${escapeHtml(nfcTitle)}</strong><p>${escapeHtml(nfcNote)}</p>${nfcCapabilities.length ? `<div>${nfcCapabilities.slice(0, 6).map((capability) => `<i>${escapeHtml(capability.name)}</i>`).join("")}</div>` : ""}</div>
<button type="button" data-open-nfc-learning>${nfcDevice ? "查看能力" : nfcCard?.uid ? "建立档案" : "开始感应"}</button> <button type="button" data-open-nfc-binding>${nfcDevice ? "查看授权" : nfcCard?.uid ? "关联设备" : "开始感应"}</button>
</section> </section>
<div class="v2-extension-gateway"> <div class="v2-extension-gateway">
<span><i data-lucide="usb"></i></span> <span><i data-lucide="usb"></i></span>
@@ -3451,7 +3696,7 @@
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div> <div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
</article>`).join("") : ` </article>`).join("") : `
<div class="v2-extension-empty"> <div class="v2-extension-empty">
<i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无可显示能力" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "仅显示已绑定并完成红外学习的动作" : "将已绑定且已学习的 NFC 外设靠近读卡器后显示能力"}</span></div> <i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}</span></div>
</div>`} </div>`}
</div> </div>
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}"> <footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
@@ -3478,7 +3723,11 @@
render(); render();
renderNfcDialog(); renderNfcDialog();
startNfcPolling(); startNfcPolling();
void syncPeripheralCatalog(); void hydratePeripheralCatalog()
.catch((error) => {
console.warn("恢复后端红外能力目录失败,将保留浏览器本地目录。", error);
})
.finally(() => syncPeripheralCatalog());
return true; return true;
} }
+5 -5
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v214"; const CACHE_VERSION = "cognitive-agent-pad-v218";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [ const APP_SHELL = [
@@ -9,10 +9,10 @@ const APP_SHELL = [
"/app.js?v=dual-seat-107", "/app.js?v=dual-seat-107",
"/gesture-control.js?v=android-pad-9", "/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3", "/pwa.js?v=android-pad-3",
"/console-v2.css?v=sync-layout-41", "/console-v2.css?v=delivery-state-43",
"/console-v2.js?v=task-outcome-47", "/console-v2.js?v=delivery-state-49",
"/self-extension.css?v=gscu-tcp-37", "/self-extension.css?v=gscu-tcp-39",
"/self-extension.js?v=gscu-tcp-37", "/self-extension.js?v=gscu-tcp-40",
"/assets/pets/self-extension-cat.png?v=3", "/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3", "/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest", "/manifest.webmanifest",
+75 -13
View File
@@ -1507,7 +1507,11 @@ class SeatDemoService:
merged.update( merged.update(
{ {
"phase": "two_stage_complete", "phase": "two_stage_complete",
"utterance": str(manifest["utterance"]), "utterance": str(
preview.get("utterance")
if preview.get("orchestration_route") == "unified_can_infrared"
else manifest["utterance"]
),
"provider_requested": selected_provider, "provider_requested": selected_provider,
"preview_request_id": preview_request_id, "preview_request_id": preview_request_id,
"supplement_of": preview_request_id, "supplement_of": preview_request_id,
@@ -1574,7 +1578,24 @@ class SeatDemoService:
merged["phase_execution_reports"] = { merged["phase_execution_reports"] = {
"local_rule": deepcopy(preview.get("execution_report")), "local_rule": deepcopy(preview.get("execution_report")),
"cloud_supplement": deepcopy(supplement.get("execution_report")), "cloud_supplement": deepcopy(supplement.get("execution_report")),
**deepcopy(preview.get("phase_execution_reports") or {}),
} }
merged["operation_results"] = [
*deepcopy(preview.get("operation_results") or []),
*deepcopy(supplement.get("operation_results") or []),
]
if preview.get("orchestration_route") == "unified_can_infrared":
merged["orchestration_route"] = "unified_can_infrared"
for key in (
"infrared_actions",
"peripheral",
"control_transports",
):
merged[key] = deepcopy(preview.get(key))
merged["provider_used"] = (
f"{supplement.get('provider_used') or 'cloud_supplement'}"
"+infrared_local_rules"
)
if supplement.get("execution_report") is None: if supplement.get("execution_report") is None:
merged["execution_report"] = deepcopy(preview.get("execution_report")) merged["execution_report"] = deepcopy(preview.get("execution_report"))
successful = {"succeeded", "partially_succeeded", "no_action"} successful = {"succeeded", "partially_succeeded", "no_action"}
@@ -2039,18 +2060,6 @@ class SeatDemoService:
"hardware mode refuses virtual or unverified vehicle actions: " "hardware mode refuses virtual or unverified vehicle actions: "
+ ", ".join(unsupported) + ", ".join(unsupported)
) )
if parsed.get("actions"):
(
self.vehicle_mqtt_settings
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.hardware_settings
).authorize(approval_token)
if parsed.get("native_can_actions"):
(
self.vehicle_mqtt_settings
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings
).authorize(approval_token)
if not parsed.get("actions") and not parsed.get("native_can_actions"): if not parsed.get("actions") and not parsed.get("native_can_actions"):
raise HardwareSafetyError( raise HardwareSafetyError(
"hardware mode requires at least one verified seat or vehicle CAN operation" "hardware mode requires at least one verified seat or vehicle CAN operation"
@@ -2192,6 +2201,7 @@ class SeatDemoService:
simulation_fault=simulation_fault, simulation_fault=simulation_fault,
vehicle_profile=vehicle_profile, vehicle_profile=vehicle_profile,
control_transport=control_transport, control_transport=control_transport,
approval_token=approval_token,
on_commands_validated=on_commands_validated, on_commands_validated=on_commands_validated,
) )
@@ -2467,6 +2477,13 @@ class SeatDemoService:
self._emit_validated_commands(result, on_commands_validated) self._emit_validated_commands(result, on_commands_validated)
if hardware_request:
self._authorize_compiled_hardware_delivery(
parsed,
approval_token,
control_transport,
)
safety_started = time.perf_counter() safety_started = time.perf_counter()
self._trace( self._trace(
traces, traces,
@@ -4162,6 +4179,34 @@ class SeatDemoService:
event["execution_report"] = None event["execution_report"] = None
callback(event) callback(event)
def _authorize_compiled_hardware_delivery(
self,
parsed: dict[str, Any],
approval_token: str | None,
control_transport: str,
) -> None:
"""Authorize physical transports after commands are safe to display.
Parsing, schema validation and deterministic CAN compilation do not
transmit frames. Keeping transport readiness checks after the
``commands_validated`` callback lets every UI target show the same
parsed commands, while these gates still run before any executor can
open a CAN or MQTT transmission path.
"""
if parsed.get("actions"):
(
self.vehicle_mqtt_settings
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.hardware_settings
).authorize(approval_token)
if parsed.get("native_can_actions"):
(
self.vehicle_mqtt_settings
if control_transport == WIRELESS_MQTT_TRANSPORT
else self.vehicle_can_settings
).authorize(approval_token)
def _process_orchestrated( def _process_orchestrated(
self, self,
utterance: str, utterance: str,
@@ -4178,6 +4223,7 @@ class SeatDemoService:
simulation_fault: str = "normal", simulation_fault: str = "normal",
vehicle_profile: VehicleProfile | None = None, vehicle_profile: VehicleProfile | None = None,
control_transport: str = WIRED_CAN_TRANSPORT, control_transport: str = WIRED_CAN_TRANSPORT,
approval_token: str | None = None,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None, on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run isolated adapters under the selected physical-bus policy.""" """Run isolated adapters under the selected physical-bus policy."""
@@ -4444,6 +4490,13 @@ class SeatDemoService:
self._emit_validated_commands(result, on_commands_validated) self._emit_validated_commands(result, on_commands_validated)
if hardware_request:
self._authorize_compiled_hardware_delivery(
parsed,
approval_token,
control_transport,
)
self._trace( self._trace(
traces, traces,
request_id, request_id,
@@ -5478,6 +5531,15 @@ class SeatDemoService:
} }
) )
def persist_composed_result(self, result: dict[str, Any]) -> None:
"""Persist a validated result composed with a non-CAN control domain."""
request_id = str(result.get("request_id") or "")
if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(request_id):
raise ValueError("invalid composed request id")
result.setdefault("trace_events", [])
self._persist(result)
def _persist(self, result: dict[str, Any]) -> None: def _persist(self, result: dict[str, Any]) -> None:
request_dir = self.artifact_root / result["request_id"] request_dir = self.artifact_root / result["request_id"]
(request_dir / "functions").mkdir(parents=True, exist_ok=True) (request_dir / "functions").mkdir(parents=True, exist_ok=True)
File diff suppressed because it is too large Load Diff
+499 -18
View File
@@ -12,6 +12,7 @@ import re
import socket import socket
import threading import threading
import time import time
from copy import deepcopy
from http.cookies import CookieError, SimpleCookie from http.cookies import CookieError, SimpleCookie
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -39,6 +40,7 @@ from cognitive_agent.application.self_extension import ( # noqa: E402
GatewayError, GatewayError,
SelfExtensionService, SelfExtensionService,
active_peripheral_plan_catalog, active_peripheral_plan_catalog,
partition_active_peripheral_intent,
resolve_active_peripheral_command, resolve_active_peripheral_command,
) )
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402 from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
@@ -324,6 +326,26 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.SERVICE_UNAVAILABLE,
) )
return return
if path == "/api/extension/peripherals":
try:
catalog = self.extension.list_peripheral_capabilities()
self._json(
{
"success": True,
"catalog": catalog,
"count": len(catalog),
}
)
except GatewayError as exc:
self._json(
{
"success": False,
"error": "catalog_unavailable",
"message": str(exc),
},
HTTPStatus.SERVICE_UNAVAILABLE,
)
return
if path == "/api/extension/gscu/info": if path == "/api/extension/gscu/info":
self._handle_extension_gateway("info", {}) self._handle_extension_gateway("info", {})
return return
@@ -537,11 +559,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
else: else:
result = None result = None
if path in {"/api/requests", "/api/requests/preview"}: if path in {"/api/requests", "/api/requests/preview"}:
result = self._execute_active_peripheral_request( result = self._execute_request_with_peripherals(
utterance, path,
provider=provider, common_arguments,
conversation_id=conversation_id,
execution_mode=execution_mode,
) )
if result is None: if result is None:
if path == "/api/requests/preview": if path == "/api/requests/preview":
@@ -670,7 +690,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) -> dict[str, object] | None: ) -> dict[str, object] | None:
"""Route a command to the NFC-bound peripheral when one is present.""" """Route a command to the NFC-bound peripheral when one is present."""
extension_catalog = self.vehicle_display_sync.extension_catalog() # The browser may disappear or reload between learning and execution.
# Natural-language routing therefore reads the durable, validated
# whitelist instead of trusting the display-sync snapshot.
extension_catalog = self.extension.list_peripheral_capabilities()
nfc_snapshot = self.nfc.snapshot() nfc_snapshot = self.nfc.snapshot()
plan_catalog = active_peripheral_plan_catalog( plan_catalog = active_peripheral_plan_catalog(
utterance, utterance,
@@ -703,6 +726,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"utterance": utterance, "utterance": utterance,
"peripherals": plan_catalog, "peripherals": plan_catalog,
"vehicle_devices": [], "vehicle_devices": [],
"provider": provider,
} }
) )
if plan_result.get("needs_confirmation"): if plan_result.get("needs_confirmation"):
@@ -748,6 +772,465 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) )
return result return result
@staticmethod
def _residual_has_vehicle_language(utterance: str) -> bool:
"""Recognize only obvious vehicle-domain text in a claimed mixed request."""
return bool(
re.search(
r"空调|温度|风量|循环|主驾|副驾|座椅|车窗|车门|后门|"
r"遮阳帘|阅读灯|氛围灯|室内灯|出风口|风口|"
r"\b(?:ac|seat|window|door|shade|light|outlet)\b",
utterance,
flags=re.IGNORECASE,
)
)
def _execute_local_peripheral_actions(
self,
utterance: str,
entries: list[dict[str, object]],
*,
provider: str,
conversation_id: str | None,
execution_mode: str,
) -> dict[str, object]:
"""Execute already validated local-rule infrared capabilities."""
capabilities = [
{
**dict(entry["capability"]),
"parse_source": "local_rule",
}
for entry in entries
if isinstance(entry, dict) and isinstance(entry.get("capability"), dict)
]
if not capabilities:
raise ExtensionIntegrationError("没有可执行的已学习红外能力。")
conversation_turn = 1
if conversation_id:
context = self.service.conversations.snapshot(conversation_id)
conversation_turn = int(context.get("turn_count", 0)) + 1
if len(capabilities) == 1:
result = self.extension.execute_peripheral_request(
utterance,
capabilities[0],
conversation_id=conversation_id,
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
)
else:
result = self.extension.execute_peripheral_plan_request(
utterance,
{
"summary": f"本地规则命中 {len(capabilities)} 个已学习红外动作。",
"provider_used": "infrared_local_rules",
"steps": [
{
"kind": "peripheral",
"capability_id": capability["id"],
}
for capability in capabilities
],
},
capabilities,
conversation_id=conversation_id,
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
)
result["provider_used"] = "infrared_local_rules"
self.vehicle_display_sync.update_extension(
action={
"type": "emit_sequence" if len(capabilities) > 1 else "emit",
"source": "main_orchestration_local_rule",
"capability_ids": [item["id"] for item in capabilities],
"slots": [item["slot"] for item in capabilities],
},
result=result,
)
return result
@staticmethod
def _merge_can_and_peripheral_results(
utterance: str,
can_result: dict[str, object],
infrared_result: dict[str, object],
entries: list[dict[str, object]],
) -> dict[str, object]:
"""Create one response while retaining each transport's evidence."""
merged = deepcopy(can_result)
can_status = str(can_result.get("status") or "failed")
infrared_status = str(infrared_result.get("status") or "failed")
successful = {"succeeded", "partially_succeeded", "no_action"}
can_ok = can_status in successful
infrared_ok = infrared_status in successful
function_configs = deepcopy(can_result.get("function_configs") or {})
function_configs.update(
deepcopy(infrared_result.get("function_configs") or {})
)
command_plans = deepcopy(can_result.get("command_plans") or {})
command_plans.update(deepcopy(infrared_result.get("command_plans") or {}))
can_operations = list(can_result.get("operation_results") or [])
infrared_operations = list(infrared_result.get("operation_results") or [])
operation_results = [*can_operations, *infrared_operations]
can_report = deepcopy(can_result.get("execution_report"))
infrared_report = deepcopy(infrared_result.get("execution_report"))
execution_report = can_report or infrared_report
if isinstance(execution_report, dict):
spec = execution_report.setdefault("spec", {})
if execution_report is can_report and isinstance(infrared_report, dict):
spec["results"] = [
*list(spec.get("results") or []),
*list((infrared_report.get("spec") or {}).get("results") or []),
]
spec["status"] = (
"succeeded" if can_ok and infrared_ok else "partially_succeeded"
)
metadata = execution_report.setdefault("metadata", {})
labels = metadata.setdefault("labels", {})
labels["route"] = "unified_can_infrared"
infrared_actions = [
{
"index": entry.get("index"),
"clause": entry.get("clause"),
"capability_id": capability.get("id"),
"device_id": capability.get("device_id"),
"device_name": capability.get("device_name"),
"action": capability.get("action"),
"slot": capability.get("slot"),
"parse_source": str(
capability.get("parse_source") or "local_rule"
),
"transport": str(infrared_result.get("control_transport") or "gscu_tcp"),
}
for entry in entries
for capability in [entry.get("capability")]
if isinstance(capability, dict)
]
merged.update(
{
"utterance": utterance,
"status": (
"succeeded"
if can_ok and infrared_ok
else "partially_succeeded"
if can_ok or infrared_ok
else "failed"
),
"success": can_ok and infrared_ok,
"orchestration_route": "unified_can_infrared",
"provider_used": (
f"{can_result.get('provider_used') or 'vehicle'}+"
f"{infrared_result.get('provider_used') or 'infrared_local_rules'}"
),
"decision_summary": [
*list(can_result.get("decision_summary") or []),
*list(infrared_result.get("decision_summary") or []),
"CAN 与红外动作已通过独立白名单校验并按各自传输链路执行。",
],
"function_configs": function_configs,
"command_plans": command_plans,
"function_config": next(iter(function_configs.values()), None),
"command_plan": next(iter(command_plans.values()), None),
"execution_report": execution_report,
"operation_results": operation_results,
"infrared_actions": infrared_actions,
"peripheral": infrared_result.get("peripheral"),
"control_transports": {
"vehicle": can_result.get("control_transport"),
"infrared": infrared_result.get("control_transport"),
},
"phase_execution_reports": {
**dict(can_result.get("phase_execution_reports") or {}),
"vehicle": can_report,
"infrared": infrared_report,
},
}
)
return merged
@staticmethod
def _apply_nfc_command_denials(
result: dict[str, object],
entries: list[dict[str, object]],
) -> dict[str, object]:
"""Expose command-level NFC denials without discarding allowed work."""
if not entries:
return result
denied_actions = [
{
"index": entry.get("index"),
"clause": entry.get("clause"),
"capability_id": capability.get("id"),
"device_id": capability.get("device_id"),
"device_name": capability.get("device_name"),
"action": capability.get("action"),
"slot": capability.get("slot"),
"reason": str(entry.get("reason") or "nfc_command_disabled"),
}
for entry in entries
for capability in [entry.get("capability")]
if isinstance(entry, dict) and isinstance(capability, dict)
]
if not denied_actions:
return result
result["denied_peripheral_actions"] = denied_actions
result["authorization_filtered"] = True
summaries = list(result.get("decision_summary") or [])
labels = "、".join(
str(item.get("clause") or item.get("action") or "未命名动作")
for item in denied_actions
)
summaries.append(
f"当前 NFC 未授权 {len(denied_actions)} 条红外命令,已过滤:{labels}。"
)
result["decision_summary"] = summaries
prior_status = str(result.get("status") or "failed")
if prior_status in {"succeeded", "partially_succeeded", "no_action"}:
result["status"] = "partially_succeeded"
result["success"] = False
execution_report = result.get("execution_report")
if isinstance(execution_report, dict):
spec = execution_report.get("spec")
if isinstance(spec, dict):
spec["status"] = "partially_succeeded"
return result
def _execute_request_with_peripherals(
self,
path: str,
common_arguments: dict[str, object],
*,
on_commands_validated: object | None = None,
) -> dict[str, object] | None:
"""Route IR-only or mixed IR/CAN requests without changing CAN parsing."""
utterance = str(common_arguments.get("utterance") or "").strip()
catalog = self.extension.list_peripheral_capabilities()
nfc_snapshot = self.nfc.snapshot()
partition = partition_active_peripheral_intent(
utterance,
catalog,
nfc_snapshot,
)
entries = list(partition.get("peripheral_actions") or [])
residual = str(partition.get("residual_utterance") or "").strip()
unresolved = list(partition.get("unresolved_peripheral_clauses") or [])
denied = list(partition.get("denied_peripheral_actions") or [])
provider = str(common_arguments.get("provider") or "auto")
conversation_id = (
str(common_arguments["conversation_id"])
if common_arguments.get("conversation_id")
else None
)
execution_mode = str(common_arguments.get("execution_mode") or "dry_run")
infrared_result: dict[str, object] | None = None
if unresolved:
if provider == "local":
raise ExtensionIntegrationError(
"本地规则已识别外设,但以下动作未唯一命中已学习能力:"
+ "、".join(str(item) for item in unresolved)
)
candidates = list(partition.get("unresolved_candidates") or [])
if not candidates:
raise ExtensionIntegrationError("没有可供云端补充选择的已学习红外能力。")
plan_result = self.extension.plan(
{
"utterance": ",".join(str(item) for item in unresolved),
"peripherals": candidates,
"vehicle_devices": [],
"provider": provider,
}
)
if plan_result.get("needs_confirmation"):
raise ExtensionIntegrationError(
str(plan_result.get("question") or "请明确要控制的红外动作。")
)
cloud_steps = list(plan_result.get("steps") or [])
if not cloud_steps:
raise ExtensionIntegrationError("云端模型没有命中已学习红外能力。")
candidate_by_id = {
str(item.get("id")): {**item, "parse_source": "cloud_model"}
for item in candidates
if isinstance(item, dict) and item.get("id")
}
local_capabilities = [
{
**dict(entry["capability"]),
"parse_source": "local_rule",
}
for entry in entries
if isinstance(entry, dict)
and isinstance(entry.get("capability"), dict)
]
selected_cloud = [
candidate_by_id[str(step.get("capability_id"))]
for step in cloud_steps
if isinstance(step, dict)
and step.get("kind") == "peripheral"
and str(step.get("capability_id")) in candidate_by_id
]
execution_catalog = {
str(item["id"]): item for item in [*local_capabilities, *selected_cloud]
}
combined_steps = [
{"kind": "peripheral", "capability_id": item["id"]}
for item in local_capabilities
] + cloud_steps
conversation_turn = 1
if conversation_id:
context = self.service.conversations.snapshot(conversation_id)
conversation_turn = int(context.get("turn_count", 0)) + 1
infrared_result = self.extension.execute_peripheral_plan_request(
utterance,
{
**plan_result,
"steps": combined_steps,
},
list(execution_catalog.values()),
conversation_id=conversation_id,
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
)
next_index = max(
[int(entry.get("index", 0)) for entry in entries if isinstance(entry, dict)],
default=-1,
) + 1
entries.extend(
{
"index": next_index + index,
"clause": ",".join(str(item) for item in unresolved),
"capability": capability,
}
for index, capability in enumerate(selected_cloud)
)
if not entries and not denied:
return self._execute_active_peripheral_request(
utterance,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
)
if not entries:
if not residual:
labels = "、".join(
str(item.get("clause") or "未命名动作")
for item in denied
if isinstance(item, dict)
)
raise ExtensionIntegrationError(
f"当前 NFC 未授权执行以下红外命令:{labels}。"
)
can_arguments = {**common_arguments, "utterance": residual}
callback = (
on_commands_validated
if callable(on_commands_validated)
else None
)
if path == "/api/requests/preview":
result = self.service.preview_native_can(
**can_arguments,
on_commands_validated=callback,
)
else:
result = self.service.process(
**can_arguments,
_on_commands_validated=callback,
)
result = self._apply_nfc_command_denials(result, denied)
self.service.persist_composed_result(result)
if callback is not None:
callback(result)
return result
if (
infrared_result is None
and residual
and not self._residual_has_vehicle_language(residual)
):
# Preserve the richer existing ordered/delay peripheral planner
# when the remaining text contains no vehicle-domain request.
planned = self._execute_active_peripheral_request(
utterance,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
)
if planned is not None:
return planned
if infrared_result is None:
infrared_result = self._execute_local_peripheral_actions(
utterance,
entries,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
)
if not residual:
infrared_result = self._apply_nfc_command_denials(
infrared_result, denied
)
if denied:
self.service.persist_composed_result(infrared_result)
return infrared_result
can_arguments = {**common_arguments, "utterance": residual}
callback = on_commands_validated if callable(on_commands_validated) else None
can_validation_callback = None
if callback is not None:
def publish_combined_validation(
validated_can: dict[str, object],
) -> None:
# Native CAN publishes its fully compiled commands before the
# physical executor starts. A mixed request has already
# compiled (and in hardware mode may already have submitted)
# the infrared phase, so never replace the UI with this
# CAN-only intermediate snapshot. If CAN subsequently fails,
# this combined validation event remains the authoritative
# parsed-command view with per-operation delivery evidence.
validated = self._merge_can_and_peripheral_results(
utterance,
validated_can,
infrared_result,
entries,
)
validated = self._apply_nfc_command_denials(validated, denied)
validated["status"] = "processing"
validated["phase"] = "commands_validated"
callback(validated)
can_validation_callback = publish_combined_validation
if path == "/api/requests/preview":
can_result = self.service.preview_native_can(
**can_arguments,
on_commands_validated=can_validation_callback,
)
else:
can_result = self.service.process(
**can_arguments,
_on_commands_validated=can_validation_callback,
)
combined = self._merge_can_and_peripheral_results(
utterance,
can_result,
infrared_result,
entries,
)
combined = self._apply_nfc_command_denials(combined, denied)
self.service.persist_composed_result(combined)
if callback is not None:
callback(combined)
return combined
def _stream_control_request( def _stream_control_request(
self, self,
path: str, path: str,
@@ -819,17 +1302,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
try: try:
result = None result = None
if path in {"/api/requests", "/api/requests/preview"}: if path in {"/api/requests", "/api/requests/preview"}:
result = self._execute_active_peripheral_request( result = self._execute_request_with_peripherals(
utterance, path,
provider=str(common_arguments.get("provider") or "auto"), common_arguments,
conversation_id=( on_commands_validated=on_commands_validated,
str(common_arguments["conversation_id"])
if common_arguments.get("conversation_id")
else None
),
execution_mode=str(
common_arguments.get("execution_mode") or "dry_run"
),
) )
if result is None: if result is None:
if path == "/api/requests/preview": if path == "/api/requests/preview":
@@ -1052,7 +1528,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
catalog = payload.get("peripherals", payload.get("catalog", [])) catalog = payload.get("peripherals", payload.get("catalog", []))
if not isinstance(catalog, list) or len(catalog) > 248: if not isinstance(catalog, list) or len(catalog) > 248:
raise ValueError("外设能力目录必须是至多 248 项的数组。") raise ValueError("外设能力目录必须是至多 248 项的数组。")
sync_result = {"status": "ready", "count": len(catalog)} durable_sync = self.extension.sync_peripheral_catalog(catalog)
sync_result = {
"status": "ready",
"count": len(catalog),
"learned_count": durable_sync["count"],
}
self.vehicle_display_sync.update_extension( self.vehicle_display_sync.update_extension(
catalog=catalog, catalog=catalog,
action={ action={
+595 -15
View File
@@ -33,6 +33,7 @@ class SeatWebApiTests(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
cls.temporary_directory = tempfile.TemporaryDirectory() cls.temporary_directory = tempfile.TemporaryDirectory()
cls.extension_temporary_directory = tempfile.TemporaryDirectory()
# Web regression tests must never inherit a developer's live bench # Web regression tests must never inherit a developer's live bench
# enablement, especially when local debug mode intentionally skips the # enablement, especially when local debug mode intentionally skips the
# per-request approval token. # per-request approval token.
@@ -50,6 +51,13 @@ class SeatWebApiTests(unittest.TestCase):
cls.service = service cls.service = service
cls.original_service = SeatDemoHandler.service cls.original_service = SeatDemoHandler.service
SeatDemoHandler.service = service SeatDemoHandler.service = service
cls.original_extension = SeatDemoHandler.extension
SeatDemoHandler.extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
cls.extension_temporary_directory.name, "gscu-ir-catalog.sqlite3"
),
)
cls.original_auth = SeatDemoHandler.auth cls.original_auth = SeatDemoHandler.auth
SeatDemoHandler.auth = AuthRuntime( SeatDemoHandler.auth = AuthRuntime(
AuthConfig( AuthConfig(
@@ -79,7 +87,9 @@ class SeatWebApiTests(unittest.TestCase):
cls.server.server_close() cls.server.server_close()
cls.thread.join(timeout=2) cls.thread.join(timeout=2)
SeatDemoHandler.service = cls.original_service SeatDemoHandler.service = cls.original_service
SeatDemoHandler.extension = cls.original_extension
SeatDemoHandler.auth = cls.original_auth SeatDemoHandler.auth = cls.original_auth
cls.extension_temporary_directory.cleanup()
cls.temporary_directory.cleanup() cls.temporary_directory.cleanup()
def setUp(self) -> None: def setUp(self) -> None:
@@ -341,19 +351,19 @@ operations:
) as response: ) as response:
gesture_script = response.read().decode("utf-8") gesture_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/console-v2.js?v=task-outcome-47", timeout=3 f"{self.base_url}/console-v2.js?v=delivery-state-49", timeout=3
) as response: ) as response:
console_script = response.read().decode("utf-8") console_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/console-v2.css?v=sync-layout-41", timeout=3 f"{self.base_url}/console-v2.css?v=delivery-state-43", timeout=3
) as response: ) as response:
console_styles = response.read().decode("utf-8") console_styles = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/self-extension.js?v=gscu-tcp-37", timeout=3 f"{self.base_url}/self-extension.js?v=gscu-tcp-40", timeout=3
) as response: ) as response:
extension_script = response.read().decode("utf-8") extension_script = response.read().decode("utf-8")
with self.opener.open( with self.opener.open(
f"{self.base_url}/self-extension.css?v=gscu-tcp-37", timeout=3 f"{self.base_url}/self-extension.css?v=gscu-tcp-39", timeout=3
) as response: ) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n") extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open( with self.opener.open(
@@ -388,13 +398,13 @@ operations:
self.assertIn('href="/styles.css?v=dual-seat-16"', html) self.assertIn('href="/styles.css?v=dual-seat-16"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html) self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html) self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=task-outcome-47"', html) self.assertIn('src="/console-v2.js?v=delivery-state-49"', html)
self.assertIn('href="/console-v2.css?v=sync-layout-41"', html) self.assertIn('href="/console-v2.css?v=delivery-state-43"', html)
self.assertIn('<body class="console-v2">', html) self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html) self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html) self.assertIn('正在加载车机控制与实时状态', html)
self.assertIn('src="/self-extension.js?v=gscu-tcp-37"', html) self.assertIn('src="/self-extension.js?v=gscu-tcp-40"', html)
self.assertIn('href="/self-extension.css?v=gscu-tcp-37"', html) self.assertIn('href="/self-extension.css?v=gscu-tcp-39"', html)
self.assertNotIn("self-calibration", html) self.assertNotIn("self-calibration", html)
self.assertNotIn("self-repair", html) self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html) self.assertNotIn("self-evolution", html)
@@ -639,7 +649,7 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script) self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script) self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script) self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v214"', service_worker) self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v218"', service_worker)
self.assertIn('"/app.js?v=dual-seat-107"', service_worker) self.assertIn('"/app.js?v=dual-seat-107"', service_worker)
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker) self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
self.assertIn(".then(() => self.skipWaiting())", service_worker) self.assertIn(".then(() => self.skipWaiting())", service_worker)
@@ -1013,7 +1023,12 @@ operations:
def test_self_extension_gateway_uses_unified_authenticated_service(self) -> None: def test_self_extension_gateway_uses_unified_authenticated_service(self) -> None:
original_extension = SeatDemoHandler.extension original_extension = SeatDemoHandler.extension
SeatDemoHandler.extension = SelfExtensionService(SimulationGateway()) SeatDemoHandler.extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "gateway-auth.sqlite3"
),
)
try: try:
with self.opener.open( with self.opener.open(
f"{self.base_url}/api/extension/health", timeout=3 f"{self.base_url}/api/extension/health", timeout=3
@@ -1039,7 +1054,12 @@ operations:
def test_self_extension_tcp_configuration_endpoint_updates_runtime(self) -> None: def test_self_extension_tcp_configuration_endpoint_updates_runtime(self) -> None:
original_extension = SeatDemoHandler.extension original_extension = SeatDemoHandler.extension
extension = SelfExtensionService(SimulationGateway()) extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "tcp-config.sqlite3"
),
)
SeatDemoHandler.extension = extension SeatDemoHandler.extension = extension
try: try:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -1093,11 +1113,38 @@ operations:
with self.opener.open(request, timeout=3) as response: with self.opener.open(request, timeout=3) as response:
self.assertEqual(response.status, 200) self.assertEqual(response.status, 200)
structured_request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/slots/5",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"device_id": "toy-light-001",
"object_names": ["闪烁灯", "闪灯玩具"],
"action_names": ["切换为蓝色", "设置为蓝色"],
"source": "tcp",
},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(structured_request, timeout=3) as response:
structured = json.loads(response.read().decode("utf-8"))
self.assertEqual(structured["slot"]["device_id"], "toy-light-001")
self.assertEqual(
structured["slot"]["object_names"], ["闪烁灯", "闪灯玩具"]
)
self.assertEqual(
structured["slot"]["action_names"],
["切换为蓝色", "设置为蓝色"],
)
with self.opener.open( with self.opener.open(
f"{self.base_url}/api/extension/gscu/slots", timeout=3 f"{self.base_url}/api/extension/gscu/slots", timeout=3
) as response: ) as response:
catalog = json.loads(response.read().decode("utf-8")) catalog = json.loads(response.read().decode("utf-8"))
self.assertEqual([item["slot"] for item in catalog["slots"]], [3, 12]) self.assertEqual(
[item["slot"] for item in catalog["slots"]], [3, 5, 12]
)
delete_request = urllib.request.Request( delete_request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/slots/3", f"{self.base_url}/api/extension/gscu/slots/3",
@@ -1117,7 +1164,7 @@ operations:
with self.opener.open(erase_request, timeout=3) as response: with self.opener.open(erase_request, timeout=3) as response:
erased = json.loads(response.read().decode("utf-8")) erased = json.loads(response.read().decode("utf-8"))
self.assertTrue(erased["catalog_cleared"]) self.assertTrue(erased["catalog_cleared"])
self.assertEqual(erased["catalog_removed_count"], 1) self.assertEqual(erased["catalog_removed_count"], 2)
with self.opener.open( with self.opener.open(
f"{self.base_url}/api/extension/gscu/slots", timeout=3 f"{self.base_url}/api/extension/gscu/slots", timeout=3
@@ -1130,7 +1177,12 @@ operations:
def test_main_request_routes_live_nfc_peripheral_to_gscu(self) -> None: def test_main_request_routes_live_nfc_peripheral_to_gscu(self) -> None:
original_extension = SeatDemoHandler.extension original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc original_nfc = SeatDemoHandler.nfc
SeatDemoHandler.extension = SelfExtensionService(SimulationGateway()) SeatDemoHandler.extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "main-nfc-route.sqlite3"
),
)
SeatDemoHandler.nfc = SimpleNamespace( SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: { snapshot=lambda: {
"revision": 1, "revision": 1,
@@ -1197,10 +1249,538 @@ operations:
self.assertEqual(operation["domain"], "self_extension") self.assertEqual(operation["domain"], "self_extension")
self.assertTrue(operation["telemetry"]["gateway_acknowledged"]) self.assertTrue(operation["telemetry"]["gateway_acknowledged"])
def test_main_request_combines_explicit_ir_clause_with_existing_can_parser(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "mixed-nfc-can.sqlite3"
),
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"revision": 1,
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
catalog_request = urllib.request.Request(
f"{self.base_url}/api/extension/display-sync",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"peripherals": [
{
"id": "toy-001-light-on",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "开启",
"action_names": ["打开", "开启", "启动"],
"risk": "low",
"slot": 1,
},
{
"id": "toy-001-light-red",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "切换到红色",
"action_names": ["切换到红色", "切换为红色", "设置为红色"],
"risk": "low",
"slot": 3,
},
]
},
ensure_ascii=False,
).encode("utf-8"),
)
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": "打开闪烁灯,颜色切换到红色,打开空调",
"provider": "local",
"conversation_id": "conv-mixed-nfc-can",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
try:
with self.opener.open(catalog_request, timeout=3) as response:
synced = json.loads(response.read().decode("utf-8"))
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
result = events[-1]["payload"]
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
self.assertEqual(synced["learned_count"], 2)
self.assertEqual(events[-1]["type"], "result")
self.assertTrue(any(item["type"] == "validated_commands" for item in events))
validated_payloads = [
item["payload"]
for item in events
if item["type"] == "validated_commands"
]
self.assertTrue(validated_payloads)
self.assertTrue(
all(
"self_extension" in payload["function_configs"]
for payload in validated_payloads
)
)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["orchestration_route"], "unified_can_infrared")
self.assertEqual(
[item["slot"] for item in result["infrared_actions"]], [1, 3]
)
self.assertEqual(
result["infrared_actions"][0]["parse_source"], "local_rule"
)
self.assertIn("self_extension", result["function_configs"])
self.assertTrue(
any(key != "self_extension" for key in result["function_configs"])
)
self.assertTrue(
any(
item.get("domain") == "self_extension"
for item in result["operation_results"]
)
)
def test_hardware_can_failure_keeps_infrared_in_validated_command_event(
self,
) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name,
"mixed-hardware-validation.sqlite3",
),
)
extension.sync_peripheral_catalog(
[
{
"id": "ir-slot:tony-light-001:1",
"status": "learned",
"enabled": True,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "打开",
"action_names": ["打开", "开启", "启动"],
"risk": "low",
"slot": 1,
},
{
"id": "ir-slot:tony-light-001:4",
"status": "learned",
"enabled": True,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "切换到绿色",
"action_names": ["切换到绿色", "切换为绿色", "设置为绿色"],
"risk": "low",
"slot": 4,
},
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": (
"打开空调,温度设置为24度,风速调整为3档,"
"打开遮阳帘,开启闪光灯,颜色设置为绿色"
),
"provider": "local",
"conversation_id": "conv-hardware-mixed-validation",
"execution_mode": "hardware",
"control_transport": "wired_can",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
def fail_can_after_validation(*_args, **kwargs):
callback = kwargs.get("_on_commands_validated")
self.assertTrue(callable(callback))
callback(
{
"request_id": "req-can-validation-only",
"utterance": "打开空调,温度设置为24度,风速调整为3档,打开遮阳帘",
"status": "processing",
"execution_mode": "hardware",
"control_transport": "wired_can",
"provider_used": "local_rules",
"decision_summary": [],
"function_configs": {
"native_climate": {
"spec": {
"operations": [
{
"id": "vehicle_set_power_1",
"capability": "climate.set_power",
"target": {"zone": "vehicle"},
"parameters": {"state": "on"},
"parse_source": "local_rule",
}
]
}
}
},
"command_plans": {
"native_climate": {"spec": {"commands": []}}
},
"operation_results": [],
"execution_report": None,
}
)
raise CanGatewayError("simulated CAN transport failure")
try:
with patch.object(
self.service,
"process",
side_effect=fail_can_after_validation,
):
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
self.assertEqual(events[-1]["type"], "error")
self.assertEqual(events[-1]["error"], "can_transport_failed")
validated = next(
item["payload"]
for item in events
if item["type"] == "validated_commands"
)
self.assertEqual(validated["status"], "processing")
self.assertEqual(
set(validated["function_configs"]),
{"native_climate", "self_extension"},
)
self.assertEqual(
[item["slot"] for item in validated["infrared_actions"]],
[1, 4],
)
def test_disabled_nfc_ir_command_does_not_discard_allowed_can_and_ir(
self,
) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name,
"mixed-disabled-nfc-command.sqlite3",
),
)
extension.sync_peripheral_catalog(
[
{
"id": "ir-slot:tony-light-001:1",
"status": "learned",
"enabled": True,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "打开",
"action_names": ["打开", "开启", "启动"],
"risk": "low",
"slot": 1,
},
{
"id": "ir-slot:tony-light-001:5",
"status": "learned",
"enabled": False,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": "切换到蓝色",
"action_names": ["切换到蓝色", "切换为蓝色", "设置为蓝色"],
"risk": "low",
"slot": 5,
},
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"revision": 1,
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": (
"打开空调,温度设置为24度,风速调整为3档,"
"开启闪光灯,颜色设置为蓝色"
),
"provider": "openai",
"conversation_id": "conv-disabled-nfc-command",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
try:
with patch.object(
extension,
"plan",
side_effect=AssertionError(
"a disabled exact command must not be sent to the cloud"
),
):
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
result = events[-1]["payload"]
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
self.assertEqual(events[-1]["type"], "result")
self.assertEqual(result["status"], "partially_succeeded")
self.assertEqual(result["orchestration_route"], "unified_can_infrared")
self.assertEqual(
[item["slot"] for item in result["infrared_actions"]], [1]
)
self.assertEqual(
[item["slot"] for item in result["denied_peripheral_actions"]],
[5],
)
self.assertEqual(
result["denied_peripheral_actions"][0]["reason"],
"nfc_command_disabled",
)
debug_commands = [
command["signals"]["debug_command"]
for plan in result["command_plans"].values()
for command in plan["spec"].get("commands", [])
if command.get("signals", {}).get("debug_command")
]
self.assertEqual(
debug_commands,
["ac on", "ac temp driver 24", "ac fan 3"],
)
infrared_slots = [
command["signals"]["slot"]
for command in result["command_plans"]["self_extension"]["spec"][
"commands"
]
]
self.assertEqual(infrared_slots, [1])
def test_generic_vehicle_light_text_never_routes_to_ir_alias(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "vehicle-light-not-ir.sqlite3"
),
)
extension.sync_peripheral_catalog(
[
{
"id": "toy-001-light-on",
"status": "learned",
"device_id": "toy-001",
"device_name": "玩具 001",
"device_aliases": ["氛围灯"],
"card_uid": "2CC8B103",
"action": "开启闪灯",
"aliases": ["打开"],
"risk": "low",
"slot": 1,
}
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
request = urllib.request.Request(
f"{self.base_url}/api/requests",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"utterance": "打开氛围灯,空调24度",
"provider": "local",
"conversation_id": "conv-vehicle-light-not-ir",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
try:
with self.opener.open(request, timeout=3) as response:
result = json.loads(response.read().decode("utf-8"))
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
self.assertNotIn("infrared_actions", result)
self.assertNotEqual(result.get("orchestration_route"), "unified_can_infrared")
self.assertNotIn("self_extension", result["function_configs"])
self.assertTrue(result["function_configs"])
def test_mixed_preview_reports_cloud_selected_infrared_capability(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name,
"mixed-cloud-nfc-can.sqlite3",
),
)
extension.sync_peripheral_catalog(
[
{
"id": "toy-001-light-red",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯"],
"card_uid": "2CC8B103",
"action": "切换到红色",
"action_names": ["切换到红色", "切换为红色"],
"slot": 3,
}
]
)
SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"reader": {"online": True},
"card": {"action": "present", "uid": "2CC8B103"},
}
)
request = urllib.request.Request(
f"{self.base_url}/api/requests/preview",
method="POST",
headers={
"Content-Type": "application/json",
"Accept": "application/x-ndjson",
},
data=json.dumps(
{
"utterance": "把闪烁灯调成喜庆颜色,打开空调",
"provider": "openai",
"conversation_id": "conv-mixed-cloud-ir",
"execution_mode": "dry_run",
"client_type": "web_console",
},
ensure_ascii=False,
).encode("utf-8"),
)
plan_result = {
"model": "gpt-5.6-luna",
"summary": "喜庆颜色对应已学习的红色动作。",
"needs_confirmation": False,
"steps": [
{"kind": "peripheral", "capability_id": "toy-001-light-red"}
],
}
try:
with patch.object(extension, "plan", return_value=plan_result) as planner:
with self.opener.open(request, timeout=3) as response:
events = [
json.loads(line)
for line in response.read().decode("utf-8").splitlines()
if line.strip()
]
result = events[-1]["payload"]
finally:
SeatDemoHandler.extension = original_extension
SeatDemoHandler.nfc = original_nfc
planner.assert_called_once()
self.assertEqual(result["orchestration_route"], "unified_can_infrared")
self.assertIn("gpt:gpt-5.6-luna", result["provider_used"])
self.assertEqual(
result["infrared_actions"][0]["parse_source"], "cloud_model"
)
self.assertFalse(result["supplement_required"])
def test_main_request_executes_gpt_nfc_sequence_without_vehicle_can(self) -> None: def test_main_request_executes_gpt_nfc_sequence_without_vehicle_can(self) -> None:
original_extension = SeatDemoHandler.extension original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc original_nfc = SeatDemoHandler.nfc
extension = SelfExtensionService(SimulationGateway()) extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(
self.extension_temporary_directory.name, "main-nfc-sequence.sqlite3"
),
)
SeatDemoHandler.extension = extension SeatDemoHandler.extension = extension
SeatDemoHandler.nfc = SimpleNamespace( SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: { snapshot=lambda: {
+53 -16
View File
@@ -10,27 +10,33 @@ def source(name: str) -> str:
return (WEB / name).read_text(encoding="utf-8") return (WEB / name).read_text(encoding="utf-8")
def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None: def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
script = source("self-extension.js") script = source("self-extension.js")
styles = source("self-extension.css") styles = source("self-extension.css")
html = source("index.html") html = source("index.html")
assert 'data-open-nfc-learning' in script assert 'data-open-nfc-binding' in script
assert '靠近读卡器后' in script assert '靠近读卡器后' in script
assert '感应玩具 NFC' in script assert 'NFC · DEVICE AUTHORIZATION' in script
assert 'NFC 设备授权' in script
assert 'api("nfc/status")' in script assert 'api("nfc/status")' in script
assert 'function createToyFromNfc()' in script
assert 'function bindCardToDevice(device, cardUid)' in script assert 'function bindCardToDevice(device, cardUid)' in script
assert 'function infraredDeviceGroups()' in script
assert 'function configuredCapabilities(deviceId = null)' in script
assert 'data-nfc-device-select' in script
assert 'data-nfc-capability-enabled' in script
assert 'data-nfc-select-all' in script
assert '全部命令已默认选中' in script
assert 'function createToyFromNfc()' not in script
assert 'data-nfc-learn-capability' not in script
assert 'data-nfc-relearn-capability' not in script
assert 'data-nfc-continue-learning' not in script
assert 'function importLegacyProfiles(value)' in script assert 'function importLegacyProfiles(value)' in script
assert 'N7WEB_REQUEST_IR_TOY_PROFILES' in script assert 'N7WEB_REQUEST_IR_TOY_PROFILES' in script
assert 'IR_TOY_LEGACY_PROFILES' in script assert 'IR_TOY_LEGACY_PROFILES' in script
assert 'value.legacy_profiles' in script assert 'value.legacy_profiles' in script
assert 'value.legacy_bridge_addresses' in script assert 'value.legacy_bridge_addresses' in script
assert 'cardUid: normalizeCardUid(cardUid)' in script assert 'device.cardUid = normalizedUid' in script
assert 'data-nfc-run-capability' in script
assert 'data-nfc-learn-capability' in script
assert 'data-nfc-relearn-capability' in script
assert '重新学习' in script
assert 'const targetSlot = relearning ? Number(capability.slot) : allocateSlot()' in script assert 'const targetSlot = relearning ? Number(capability.slot) : allocateSlot()' in script
assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' in script assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' in script
assert 'function infraredSignalFromResponse(value)' in script assert 'function infraredSignalFromResponse(value)' in script
@@ -38,19 +44,24 @@ def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None:
assert '学习指令已下发,但物理槽位回读为空' in script assert '学习指令已下发,但物理槽位回读为空' in script
assert 'signalSource: "n7web"' in script assert 'signalSource: "n7web"' in script
assert 'if (capability.signalSource === "n7web") return' in script assert 'if (capability.signalSource === "n7web") return' in script
assert '建立档案并开始学习' in script
assert 'const activeCatalog = sensedCardUid' in script assert 'const activeCatalog = sensedCardUid' in script
assert 'normalizeCardUid(item.card_uid) === sensedCardUid' in script assert 'normalizeCardUid(item.card_uid) === sensedCardUid' in script
assert '&& item.status === "learned"' in script assert '&& item.status === "learned"' in script
assert 'slot: capability.slot' in script assert 'slot: capability.slot' in script
assert 'device_aliases: device?.aliases || []' in script
assert 'async function hydratePeripheralCatalog()' in script
assert 'api("peripherals")' in script
assert 'signalSource: "backend_catalog"' in script
assert '当前未感应到外设' in script assert '当前未感应到外设' in script
assert '将已绑定且已学习的 NFC 外设靠近读卡器后显示能力' in script assert '将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令' in script
assert '.v2-nfc-inline' in styles assert '.v2-nfc-inline' in styles
assert '.v2-nfc-dialog' in styles assert '.v2-nfc-dialog' in styles
assert '.v2-nfc-capability-grid' in styles assert '.v2-nfc-command-list' in styles
assert 'self-extension.js?v=gscu-tcp-37' in html assert '.v2-nfc-command-option' in styles
assert 'self-extension.css?v=gscu-tcp-37' in html assert '.v2-nfc-select-all' in styles
assert 'self-extension.js?v=gscu-tcp-40' in html
assert 'self-extension.css?v=gscu-tcp-39' in html
def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None: def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
@@ -59,7 +70,10 @@ def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
assert 'id="extensionGscuTcpHost"' in script assert 'id="extensionGscuTcpHost"' in script
assert 'id="extensionGscuTcpPort"' in script assert 'id="extensionGscuTcpPort"' in script
assert 'id="extensionGscuCommandName"' in script assert 'id="extensionGscuDeviceId"' in script
assert 'id="extensionGscuObjectNames"' in script
assert 'id="extensionGscuActionNames"' in script
assert 'id="extensionGscuTcpResultDetails"' in script
assert 'id="extensionGscuNamedCommands"' in script assert 'id="extensionGscuNamedCommands"' in script
assert 'data-open-gscu-tcp' in script assert 'data-open-gscu-tcp' in script
assert 'api("gscu/config"' in script assert 'api("gscu/config"' in script
@@ -71,7 +85,12 @@ def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
assert "学习指令已下发,但槽位回读失败" in script assert "学习指令已下发,但槽位回读失败" in script
assert "确认擦除 GSCU1B 中的全部红外码吗" in script assert "确认擦除 GSCU1B 中的全部红外码吗" in script
assert "GSCU1B 设备中的该红外码不会被擦除" in script assert "GSCU1B 设备中的该红外码不会被擦除" in script
assert "function upsertNamedInfraredCommand(slot, name" in script assert "async function upsertNamedInfraredCommand(" in script
assert "device_id: normalizedDeviceId" in script
assert "object_names: normalizedObjects" in script
assert "action_names: normalizedActions" in script
assert "object_names: objectNames" in script
assert "action_names: actionNames" in script
assert "button.disabled = true;" in script assert "button.disabled = true;" in script
assert "buttons.forEach((item) => { item.disabled = true; });" not in script assert "buttons.forEach((item) => { item.disabled = true; });" not in script
for action in ( for action in (
@@ -92,6 +111,10 @@ def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
assert ".v2-gscu-config-form" in styles assert ".v2-gscu-config-form" in styles
assert "#extensionGscuTcpResult" in styles assert "#extensionGscuTcpResult" in styles
assert "#extensionGscuNamedCommands" in styles assert "#extensionGscuNamedCommands" in styles
assert ".v2-gscu-device-group" in styles
assert ".v2-gscu-device-command-grid" in styles
assert ".v2-gscu-result" in styles
assert "max-height: min(42vh, 360px)" in styles
def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None: def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None:
@@ -99,7 +122,21 @@ def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None
assert 'nfc = NfcReaderService()' in server assert 'nfc = NfcReaderService()' in server
assert 'path == "/api/extension/nfc/status"' in server assert 'path == "/api/extension/nfc/status"' in server
assert 'path == "/api/extension/peripherals"' in server
assert '"nfc": self.nfc.snapshot()' in server assert '"nfc": self.nfc.snapshot()' in server
assert 'SeatDemoHandler.nfc.start()' in server assert 'SeatDemoHandler.nfc.start()' in server
assert 'SeatDemoHandler.nfc.close()' in server assert 'SeatDemoHandler.nfc.close()' in server
assert "frame-src 'self' http://*:3001" in server assert "frame-src 'self' http://*:3001" in server
def test_main_parsed_control_panel_renders_validated_infrared_operations() -> None:
console = source("console-v2.js")
styles = source("console-v2.css")
html = source("index.html")
assert 'capability === "infrared.emit"' in console
assert 'channel: "IR"' in console
assert 'frameLabel: "GSCU1B TCP"' in console
assert "已解析控制指令" in console
assert 'li[data-can-channel="IR"]' in styles
assert "console-v2.js?v=delivery-state-49" in html
@@ -200,7 +200,9 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
assert "function parsedCanCommands(result = {})" in console assert "function parsedCanCommands(result = {})" in console
assert "getParsedCanCommands: (result) => parsedCanCommands(result)" in console assert "getParsedCanCommands: (result) => parsedCanCommands(result)" in console
assert "const configurations = result.function_configs || {};" in console assert "const configurations = result.function_configs || {};" in console
assert "const plans = result.command_plans || {};" in console assert "if (!configurationEntries.length && result.function_config)" in console
assert 'deliveryLabel: submitted ? "下发" : "未下发"' in console
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
assert 'const command = (category, text, channel = "CAN1", frame = "")' in console assert 'const command = (category, text, channel = "CAN1", frame = "")' in console
assert 'data-can-channel="${escapeHtml(item.channel)}"' in console assert 'data-can-channel="${escapeHtml(item.channel)}"' in console
assert 'class="v2-can-command-channel"' in console assert 'class="v2-can-command-channel"' in console
@@ -243,6 +245,8 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
assert ".v2-can-command-panel[open]" in styles assert ".v2-can-command-panel[open]" in styles
assert ".v2-can-command-list li code" in styles assert ".v2-can-command-list li code" in styles
assert ".v2-can-command-channel" in styles assert ".v2-can-command-channel" in styles
assert ".v2-can-command-delivery" in styles
assert '.v2-can-command-delivery[data-delivery-state="submitted"]' in styles
assert 'li[data-can-channel="CAN0"]' in styles assert 'li[data-can-channel="CAN0"]' in styles
assert ".v2-sidebar-collapsed .v2-can-command-panel" in styles assert ".v2-sidebar-collapsed .v2-can-command-panel" in styles
@@ -306,16 +310,117 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"command": "seat passenger backrest position 36", "command": "seat passenger backrest position 36",
"frame": "0x300 · 01 12 <SEQ_LO> <SEQ_HI> 10 0E 30 75", "frame": "0x300 · 01 12 <SEQ_LO> <SEQ_HI> 10 0E 30 75",
"parseSource": "local_rule", "parseSource": "local_rule",
"deliveryState": "not_submitted",
"deliveryLabel": "未下发",
"deliveryTransport": "有线 CAN",
}, },
{ {
"channel": "CAN1", "channel": "CAN1",
"category": "空调温度", "category": "空调温度",
"command": "ac temp driver 24", "command": "ac temp driver 24",
"parseSource": "cloud_supplement", "parseSource": "cloud_supplement",
"deliveryState": "not_submitted",
"deliveryLabel": "未下发",
"deliveryTransport": "有线 CAN",
}, },
] ]
def test_parsed_command_delivery_is_independent_of_transport_and_target() -> None:
node = shutil.which("node")
if node is None:
return
console = source("console-v2.js")
start = console.index(" const normalizedCanTarget")
end = console.index("\n\n function renderParsedCanCommands", start)
helpers = console[start:end]
base = {
"function_configs": {
"native_climate": {
"spec": {
"operations": [
{
"id": "climate-power",
"capability": "climate.set_power",
"target": {"zone": "vehicle"},
"parameters": {"state": "on"},
"parse_source": "local_rule",
}
]
}
}
}
}
samples = [
{**base, "execution_mode": "dry_run", "control_transport": "wired_can"},
{
**base,
"execution_mode": "dry_run",
"control_transport": "wireless_mqtt",
},
{
**base,
"execution_mode": "hardware",
"control_transport": "wired_can",
"execution_report": {
"spec": {
"results": [
{
"ref": "climate-power",
"status": "succeeded",
"telemetry": {"sent_frames": [{"frame_id": "0x1DE"}]},
}
]
}
},
},
{
**base,
"execution_mode": "hardware",
"control_transport": "wireless_mqtt",
"execution_report": {
"spec": {
"results": [
{
"ref": "climate-power",
"status": "succeeded",
"telemetry": {"delivery_status": "queued"},
}
]
}
},
},
]
script = (
f"{helpers}\n"
f"const samples = {json.dumps(samples)};\n"
"process.stdout.write(JSON.stringify(samples.map((sample) => "
"parsedCanCommands(sample))));"
)
completed = subprocess.run(
[node, "-e", script],
check=True,
capture_output=True,
text=True,
)
parsed = json.loads(completed.stdout)
assert [len(commands) for commands in parsed] == [1, 1, 1, 1]
assert [commands[0]["command"] for commands in parsed] == ["ac on"] * 4
assert [commands[0]["deliveryLabel"] for commands in parsed] == [
"未下发",
"未下发",
"下发",
"下发",
]
assert [commands[0]["deliveryTransport"] for commands in parsed] == [
"有线 CAN",
"无线 CAN",
"有线 CAN",
"无线 CAN",
]
def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None: def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None:
app = source("app.js") app = source("app.js")
styles = source("styles.css") styles = source("styles.css")
+554
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import os import os
import socket import socket
import sqlite3
import threading import threading
import time import time
import unittest import unittest
@@ -22,6 +23,7 @@ from cognitive_agent.application.self_extension import (
active_peripheral_plan_catalog, active_peripheral_plan_catalog,
build_gateway_command, build_gateway_command,
create_gateway, create_gateway,
partition_active_peripheral_intent,
resolve_active_peripheral_command, resolve_active_peripheral_command,
validate_slot, validate_slot,
) )
@@ -78,6 +80,216 @@ class SelfExtensionTests(unittest.TestCase):
with self.assertRaises(GatewayError): with self.assertRaises(GatewayError):
build_gateway_command("erase", {"confirm": "wrong"}) build_gateway_command("erase", {"confirm": "wrong"})
def test_learned_peripheral_whitelist_is_durable_and_normalized(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
service = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
synced = service.sync_peripheral_catalog(
[
{
"id": "toy-001-light-on",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "玩具 001",
"device_aliases": ["闪灯玩具", "闪灯玩具"],
"card_uid": "2c:c8:b1:03",
"action": "开启闪灯",
"aliases": ["打开闪灯", "点亮"],
"risk": "low",
"slot": 1,
}
]
)
self.assertEqual(synced["count"], 1)
with sqlite3.connect(database_path) as connection:
connection.execute(
"UPDATE infrared_slots SET device_id = '', "
"object_names_json = '[]', action_names_json = '[]'"
)
reloaded = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
).list_peripheral_capabilities()
self.assertEqual(len(reloaded), 1)
self.assertEqual(reloaded[0]["card_uid"], "2CC8B103")
self.assertEqual(reloaded[0]["device_aliases"], ["闪灯玩具"])
self.assertEqual(reloaded[0]["object_names"], ["玩具 001", "闪灯玩具"])
self.assertEqual(
reloaded[0]["action_names"], ["开启闪灯", "打开闪灯", "点亮"]
)
self.assertEqual(reloaded[0]["slot"], 1)
slot = service.list_infrared_slots()["slots"][0]
self.assertEqual(slot["device_id"], "toy-001")
self.assertEqual(slot["object_names"], reloaded[0]["object_names"])
self.assertEqual(slot["action_names"], reloaded[0]["action_names"])
def test_partition_claims_explicit_ir_clause_and_preserves_can_residual(self) -> None:
catalog = [
{
"id": "toy-001-light-on",
"status": "learned",
"enabled": True,
"device_id": "toy-001",
"device_name": "玩具 001",
"device_aliases": ["闪灯玩具", "氛围灯"],
"card_uid": "2CC8B103",
"action": "开启闪灯",
"aliases": ["打开闪灯", "点亮"],
"risk": "low",
"slot": 1,
}
]
present = {"card": {"action": "present", "uid": "2CC8B103"}}
partition = partition_active_peripheral_intent(
"打开闪灯玩具,同时把空调调到24度,风量2档",
catalog,
present,
)
self.assertEqual(
[item["capability"]["id"] for item in partition["peripheral_actions"]],
["toy-001-light-on"],
)
self.assertEqual(partition["residual_utterance"], "把空调调到24度,风量2档")
self.assertEqual(partition["unresolved_peripheral_clauses"], [])
def test_partition_inherits_explicit_device_for_followup_ir_action(self) -> None:
catalog = [
{
"id": capability_id,
"status": "learned",
"enabled": True,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": action,
"action_names": aliases,
"slot": slot,
}
for capability_id, action, aliases, slot in (
("light-on", "开启", ["打开", "开启", "启动"], 1),
(
"light-red",
"切换到红色",
["切换到红色", "切换为红色", "设置为红色"],
3,
),
("light-off", "关闭", ["关闭", "关掉", "熄灭"], 2),
)
]
partition = partition_active_peripheral_intent(
"打开闪烁灯,颜色切换到红色,打开空调",
catalog,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(
[item["capability"]["id"] for item in partition["peripheral_actions"]],
["light-on", "light-red"],
)
self.assertEqual(
[item["clause"] for item in partition["peripheral_actions"]],
["打开闪烁灯", "颜色切换到红色"],
)
self.assertEqual(partition["residual_utterance"], "打开空调")
self.assertEqual(partition["unresolved_peripheral_clauses"], [])
vehicle_breaks_context = partition_active_peripheral_intent(
"打开闪烁灯,打开空调,然后关闭",
catalog,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(
[
item["capability"]["id"]
for item in vehicle_breaks_context["peripheral_actions"]
],
["light-on"],
)
self.assertEqual(
vehicle_breaks_context["residual_utterance"], "打开空调,关闭"
)
def test_partition_filters_disabled_followup_without_losing_allowed_work(
self,
) -> None:
catalog = [
{
"id": capability_id,
"status": "learned",
"enabled": enabled,
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"object_names": ["闪灯玩具", "闪烁灯", "闪光灯"],
"card_uid": "2CC8B103",
"action": action,
"action_names": aliases,
"slot": slot,
}
for capability_id, action, aliases, slot, enabled in (
("light-on", "开启", ["打开", "开启", "启动"], 1, True),
(
"light-blue",
"切换到蓝色",
["切换到蓝色", "切换为蓝色", "设置为蓝色"],
5,
False,
),
)
]
partition = partition_active_peripheral_intent(
"打开空调,温度设置为24度,风速调整为3档,开启闪光灯,颜色设置为蓝色",
catalog,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(
[item["capability"]["id"] for item in partition["peripheral_actions"]],
["light-on"],
)
self.assertEqual(
partition["residual_utterance"],
"打开空调,温度设置为24度,风速调整为3档",
)
self.assertEqual(partition["unresolved_peripheral_clauses"], [])
self.assertEqual(
[
(item["clause"], item["capability"]["id"], item["reason"])
for item in partition["denied_peripheral_actions"]
],
[("颜色设置为蓝色", "light-blue", "nfc_command_disabled")],
)
def test_partition_never_claims_generic_vehicle_light_request(self) -> None:
catalog = [
{
"id": "toy-001-light-on",
"status": "learned",
"device_id": "toy-001",
"device_name": "玩具 001",
"device_aliases": ["闪灯玩具", "氛围灯"],
"card_uid": "2CC8B103",
"action": "开启闪灯",
"slot": 1,
}
]
partition = partition_active_peripheral_intent(
"打开氛围灯,空调24度",
catalog,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(partition["peripheral_actions"], [])
self.assertEqual(partition["residual_utterance"], "打开氛围灯,空调24度")
def test_infrared_slot_catalog_is_durable_sorted_and_device_safe(self) -> None: def test_infrared_slot_catalog_is_durable_sorted_and_device_safe(self) -> None:
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3") database_path = Path(directory, "infrared-slots.sqlite3")
@@ -111,6 +323,127 @@ class SelfExtensionTests(unittest.TestCase):
[12], [12],
) )
def test_infrared_slot_catalog_migrates_legacy_schema_without_data_loss(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
with sqlite3.connect(database_path) as connection:
connection.execute(
"""
CREATE TABLE infrared_slots (
slot INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source TEXT NOT NULL,
learned_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
connection.execute(
"INSERT INTO infrared_slots VALUES (?, ?, ?, ?, ?)",
(1, "打开闪烁灯", "tcp", "before", "before"),
)
service = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
slots = service.list_infrared_slots()["slots"]
self.assertEqual(slots[0]["name"], "打开闪烁灯")
self.assertEqual(slots[0]["device_id"], "")
self.assertEqual(slots[0]["object_names"], [])
self.assertEqual(slots[0]["action_names"], [])
with sqlite3.connect(database_path) as connection:
columns = {
row[1]
for row in connection.execute(
"PRAGMA table_info(infrared_slots)"
).fetchall()
}
self.assertIn("object_names_json", columns)
self.assertIn("action_names_json", columns)
self.assertIn("device_id", columns)
def test_structured_slot_names_persist_and_update_bound_whitelist(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
service = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
service.sync_peripheral_catalog(
[
{
"id": "toy-light-on",
"status": "learned",
"device_id": "toy-light",
"device_name": "灯光玩具",
"card_uid": "2CC8B103",
"action": "开启灯光",
"slot": 1,
}
]
)
saved = service.save_infrared_slot(
1,
{
"device_id": "toy-light",
"object_names": "闪烁灯,闪光灯、闪灯玩具",
"action_names": ["打开", "开启", "启动"],
"source": "tcp",
},
)["slot"]
self.assertEqual(saved["device_id"], "toy-light")
self.assertEqual(saved["object_names"], ["闪烁灯", "闪光灯", "闪灯玩具"])
self.assertEqual(saved["action_names"], ["打开", "开启", "启动"])
reloaded = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
self.assertEqual(
reloaded.list_infrared_slots()["slots"][0]["object_names"],
["闪烁灯", "闪光灯", "闪灯玩具"],
)
whitelist = reloaded.list_peripheral_capabilities()
self.assertEqual(whitelist[0]["object_names"], saved["object_names"])
self.assertEqual(whitelist[0]["action_names"], saved["action_names"])
resolved = resolve_active_peripheral_command(
"打开闪灯玩具",
whitelist,
{"card": {"uid": "2CC8B103"}},
)
self.assertEqual(resolved["id"], "toy-light-on")
changed = service.save_infrared_slot(
1,
{
"device_id": "toy-light-v2",
"object_names": ["新版闪烁灯"],
"action_names": ["打开"],
"source": "tcp",
},
)["slot"]
self.assertEqual(changed["device_id"], "toy-light-v2")
self.assertEqual(service.list_peripheral_capabilities(), [])
def test_structured_slot_rejects_invalid_or_missing_device_id(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
)
for device_id in (None, "含 空格", "-bad-prefix"):
with self.subTest(device_id=device_id), self.assertRaisesRegex(
GatewayError, "设备 ID"
):
service.save_infrared_slot(
1,
{
"device_id": device_id,
"object_names": ["闪烁灯"],
"action_names": ["打开"],
},
)
def test_erase_all_clears_catalog_only_after_valid_device_submission(self) -> None: def test_erase_all_clears_catalog_only_after_valid_device_submission(self) -> None:
with TemporaryDirectory() as directory: with TemporaryDirectory() as directory:
service = SelfExtensionService( service = SelfExtensionService(
@@ -374,6 +707,47 @@ class SelfExtensionTests(unittest.TestCase):
) )
) )
def test_structured_color_actions_require_a_unique_result_value(self) -> None:
present = {"card": {"uid": "2CC8B103"}}
catalog = [
{
"id": capability_id,
"status": "learned",
"device_name": "灯光玩具",
"object_names": ["闪烁灯", "闪灯玩具"],
"card_uid": "2CC8B103",
"action": color,
"action_names": [f"设置为{color}", f"切换到{color}", f"切换为{color}"],
"slot": slot,
}
for capability_id, color, slot in (
("blue", "蓝色", 5),
("red", "红色", 6),
)
]
resolved = resolve_active_peripheral_command(
"闪烁灯切换为蓝色", catalog, present
)
self.assertEqual(resolved["id"], "blue")
self.assertIsNone(
resolve_active_peripheral_command(
"闪烁灯切换为蓝色",
[
{**item, "enabled": item["id"] != "blue"}
for item in catalog
],
present,
)
)
ambiguous = [
{**item, "action_names": ["切换为"]}
for item in catalog
]
with self.assertRaisesRegex(ExtensionIntegrationError, "同时匹配多个"):
resolve_active_peripheral_command("闪烁灯切换为", ambiguous, present)
def test_timed_phrase_selects_live_nfc_catalog_for_gpt_plan(self) -> None: def test_timed_phrase_selects_live_nfc_catalog_for_gpt_plan(self) -> None:
catalog = [ catalog = [
{ {
@@ -410,6 +784,38 @@ class SelfExtensionTests(unittest.TestCase):
[], [],
) )
def test_exact_infrared_sequence_uses_local_rules_without_cloud(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
{
"id": capability_id,
"status": "learned",
"device_name": "第三方氛围灯",
"action": action,
"aliases": aliases,
"slot": slot,
}
for capability_id, action, aliases, slot in (
("on", "开启灯光", ["打开"], 1),
("off", "关闭灯光", ["关掉"], 2),
)
]
with patch.dict(os.environ, {"OPENAI_API_KEY": ""}, clear=False):
result = service.plan(
{
"utterance": "打开第三方氛围灯,5秒后关闭第三方氛围灯",
"peripherals": catalog,
"vehicle_devices": [],
}
)
self.assertEqual(result["provider_used"], "infrared_local_rules")
self.assertEqual(
[step["kind"] for step in result["steps"]],
["peripheral", "delay", "peripheral"],
)
def test_main_peripheral_result_uses_gscu_without_vehicle_can(self) -> None: def test_main_peripheral_result_uses_gscu_without_vehicle_can(self) -> None:
service = SelfExtensionService(SimulationGateway()) service = SelfExtensionService(SimulationGateway())
@@ -502,6 +908,151 @@ class SelfExtensionTests(unittest.TestCase):
sleep.assert_any_call(5.0) sleep.assert_any_call(5.0)
self.assertNotIn(0.5, [call.args[0] for call in sleep.call_args_list]) self.assertNotIn(0.5, [call.args[0] for call in sleep.call_args_list])
def test_failed_first_emit_retains_complete_compiled_infrared_plan(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
{
"id": capability_id,
"status": "learned",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"card_uid": "2CC8B103",
"action": action,
"slot": slot,
}
for capability_id, action, slot in (
("light-on", "打开", 1),
("light-green", "切换到绿色", 4),
)
]
plan_result = {
"provider_used": "infrared_local_rules",
"summary": "打开闪光灯并切换为绿色",
"steps": [
{"kind": "peripheral", "capability_id": "light-on"},
{"kind": "peripheral", "capability_id": "light-green"},
],
}
with patch.object(
service,
"execute_gateway",
side_effect=GatewayError("simulated infrared transport failure"),
) as execute_gateway:
result = service.execute_peripheral_plan_request(
"开启闪光灯,颜色设置为绿色",
plan_result,
catalog,
conversation_id="conv-failed-first-infrared-emit",
execution_mode="hardware",
)
execute_gateway.assert_called_once_with("emit", {"slot": 1})
self.assertEqual(result["status"], "failed")
self.assertEqual(result["sequence"]["action_count"], 2)
self.assertEqual(result["sequence"]["emit_count"], 0)
self.assertEqual(
[
item["parameters"]["slot"]
for item in result["function_config"]["spec"]["operations"]
],
[1, 4],
)
self.assertEqual(
[
item["signals"]["slot"]
for item in result["command_plan"]["spec"]["commands"]
],
[1, 4],
)
self.assertEqual(
[item["status"] for item in result["operation_results"]],
["failed"],
)
def test_submitted_emit_without_ack_continues_complete_infrared_plan(self) -> None:
service = SelfExtensionService(SimulationGateway())
catalog = [
{
"id": capability_id,
"status": "learned",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"card_uid": "2CC8B103",
"action": action,
"slot": slot,
}
for capability_id, action, slot in (
("light-on", "打开", 1),
("light-green", "切换到绿色", 4),
)
]
def submit_without_ack(_action, payload):
command = build_gateway_command("emit", payload)
return {
"success": True,
"transport": "tcp",
"request": command,
"device_response": {
"success": True,
"delivery_status": "submitted",
"transport_submitted": True,
"gateway_acknowledged": False,
},
"gateway_acknowledged": False,
}
with (
patch.object(
service,
"execute_gateway",
side_effect=submit_without_ack,
) as execute_gateway,
patch("cognitive_agent.application.self_extension.time.sleep"),
):
result = service.execute_peripheral_plan_request(
"开启闪光灯,颜色设置为绿色",
{
"provider_used": "infrared_local_rules",
"summary": "打开闪光灯并切换为绿色",
"steps": [
{"kind": "peripheral", "capability_id": "light-on"},
{"kind": "peripheral", "capability_id": "light-green"},
],
},
catalog,
conversation_id="conv-submitted-unacknowledged-infrared",
execution_mode="hardware",
)
self.assertEqual(execute_gateway.call_count, 2)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["sequence"]["emit_count"], 2)
self.assertEqual(
[item["telemetry"]["slot"] for item in result["operation_results"]],
[1, 4],
)
self.assertEqual(
[
item["telemetry"]["delivery_status"]
for item in result["operation_results"]
],
["submitted", "submitted"],
)
self.assertTrue(
all(
item["telemetry"]["transport_submitted"]
for item in result["operation_results"]
)
)
self.assertTrue(
all(
item["telemetry"]["gateway_acknowledged"] is False
for item in result["operation_results"]
)
)
def test_color_sequence_never_cycles_the_power_off_capability(self) -> None: def test_color_sequence_never_cycles_the_power_off_capability(self) -> None:
service = SelfExtensionService(SimulationGateway()) service = SelfExtensionService(SimulationGateway())
catalog = [ catalog = [
@@ -645,6 +1196,9 @@ class SelfExtensionTests(unittest.TestCase):
) -> str: ) -> str:
self.assertEqual(peripherals[0]["id"], "peripheral_1") self.assertEqual(peripherals[0]["id"], "peripheral_1")
self.assertNotIn(opaque_id, [item["id"] for item in peripherals]) self.assertNotIn(opaque_id, [item["id"] for item in peripherals])
self.assertNotIn("slot", peripherals[0])
self.assertNotIn("card_uid", peripherals[0])
self.assertNotIn("transport", peripherals[0])
return json.dumps( return json.dumps(
{ {
"summary": "切换红灯", "summary": "切换红灯",
+148
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from cognitive_agent.application.seat_service import SeatDemoService from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.server import SeatDemoHandler
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -187,7 +188,154 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
assert 'class="v2-can-command-source"' in console assert 'class="v2-can-command-source"' in console
assert 'item.parseSource === "direct_control" ? "设备控件"' in console assert 'item.parseSource === "direct_control" ? "设备控件"' in console
assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
assert 'item.deliveryLabel' in console
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
assert '"cognitive-agent:validated-can-commands"' in console assert '"cognitive-agent:validated-can-commands"' in console
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
assert '.v2-can-command-delivery[data-delivery-state="submitted"]' in styles
assert '"/api/can/commands"' in server assert '"/api/can/commands"' in server
assert '"type": "validated_commands"' in server assert '"type": "validated_commands"' in server
def test_cloud_supplement_preserves_validated_infrared_preview() -> None:
infrared_operation = {
"ref": "toy-001-light-on",
"domain": "self_extension",
"status": "succeeded",
}
preview = {
"request_id": "req-preview-ir",
"status": "succeeded",
"orchestration_route": "unified_can_infrared",
"function_configs": {
"self_extension": {
"spec": {
"operations": [
{
"capability": "infrared.emit",
"parse_source": "local_rule",
}
]
}
}
},
"command_plans": {"self_extension": {"spec": {"commands": []}}},
"decision_summary": ["红外本地规则已完成"],
"trace_events": [],
"validation": [],
"parsed_control_signatures": [],
"execution_report": {"spec": {"results": [infrared_operation]}},
"operation_results": [infrared_operation],
"infrared_actions": [{"capability_id": "toy-001-light-on", "slot": 1}],
"peripheral": {"id": "toy-001", "name": "玩具 001"},
"control_transports": {"vehicle": "wired_can", "infrared": "gscu_tcp"},
}
supplement = {
"request_id": "req-cloud-ir",
"status": "no_action",
"provider_used": "openai:test",
"function_configs": {},
"command_plans": {},
"decision_summary": [],
"trace_events": [],
"validation": [],
"parsed_control_signatures": [],
"execution_report": None,
"operation_results": [],
}
merged = SeatDemoService._merge_native_can_phases(preview, supplement)
assert merged["orchestration_route"] == "unified_can_infrared"
assert merged["infrared_actions"][0]["slot"] == 1
assert merged["peripheral"]["id"] == "toy-001"
assert merged["operation_results"] == [infrared_operation]
assert "local_self_extension" in merged["function_configs"]
def test_unified_result_preserves_cloud_infrared_attribution() -> None:
can_result = {
"status": "succeeded",
"provider_used": "local_rules",
"function_configs": {},
"command_plans": {},
"operation_results": [],
"decision_summary": [],
}
infrared_result = {
"status": "succeeded",
"provider_used": "gpt:gpt-5.6-luna",
"control_transport": "gscu_dry_run",
"function_configs": {},
"command_plans": {},
"operation_results": [],
"decision_summary": [],
}
entries = [
{
"index": 0,
"clause": "把闪烁灯调成喜庆颜色",
"capability": {
"id": "light-red",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"action": "切换到红色",
"slot": 3,
"parse_source": "cloud_model",
},
}
]
merged = SeatDemoHandler._merge_can_and_peripheral_results(
"把闪烁灯调成喜庆颜色,打开空调",
can_result,
infrared_result,
entries,
)
assert merged["provider_used"] == "local_rules+gpt:gpt-5.6-luna"
assert merged["infrared_actions"][0]["parse_source"] == "cloud_model"
def test_nfc_command_denial_keeps_allowed_result_and_reports_partial() -> None:
result = {
"status": "succeeded",
"success": True,
"decision_summary": ["已解析并验证 4 条允许的控制命令。"],
"execution_report": {"spec": {"status": "succeeded"}},
}
filtered = SeatDemoHandler._apply_nfc_command_denials(
result,
[
{
"index": 4,
"clause": "颜色设置为蓝色",
"reason": "nfc_command_disabled",
"capability": {
"id": "light-blue",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"action": "切换到蓝色",
"slot": 5,
},
}
],
)
assert filtered["status"] == "partially_succeeded"
assert filtered["success"] is False
assert filtered["authorization_filtered"] is True
assert filtered["execution_report"]["spec"]["status"] == "partially_succeeded"
assert filtered["denied_peripheral_actions"] == [
{
"index": 4,
"clause": "颜色设置为蓝色",
"capability_id": "light-blue",
"device_id": "tony-light-001",
"device_name": "闪灯玩具",
"action": "切换到蓝色",
"slot": 5,
"reason": "nfc_command_disabled",
}
]