添加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);
}
.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"] {
background: #f7f4ec;
}
@@ -7235,6 +7261,21 @@ body.device-power-drawer-open {
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 {
color: #075f5a;
font: 750 9px/1.45 "SFMono-Regular", Consolas, monospace;
+94 -21
View File
@@ -44,7 +44,9 @@
if (validatedCommandContext.requestId && requestId
&& validatedCommandContext.requestId !== requestId) return false;
if (validatedCommandContext.utterance && utterance
&& validatedCommandContext.utterance !== utterance) return false;
&& validatedCommandContext.utterance !== utterance
&& !validatedCommandContext.utterance.includes(utterance)
&& !utterance.includes(validatedCommandContext.utterance)) return false;
return true;
}
@@ -105,6 +107,22 @@
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);
if (isPassengerSeat) {
const passengerAxes = {
@@ -324,23 +342,74 @@
function parsedCanCommands(result = {}) {
const configurations = result.function_configs || {};
const plans = result.command_plans || {};
const planKeys = new Set(Object.keys(plans));
const hasCompiledPlan = planKeys.size > 0 || Boolean(result.command_plan);
if (!hasCompiledPlan) return [];
const configurationEntries = Object.entries(configurations);
if (!configurationEntries.length && result.function_config) {
configurationEntries.push(["default", result.function_config]);
}
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 = [];
Object.entries(configurations).forEach(([configKey, configuration]) => {
if (planKeys.size > 0 && !planKeys.has(configKey)) return;
configurationEntries.forEach(([_configKey, configuration]) => {
(configuration?.spec?.operations || []).forEach((operation) => {
const item = parsedCanCommandForOperation(operation);
if (item) commands.push(item);
if (item) commands.push({ ...item, ...deliveryForOperation(operation) });
});
});
const unique = new Map();
commands.forEach((item) => {
const key = `${item.channel}:${item.command}`;
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);
}
});
@@ -357,17 +426,18 @@
panel.dataset.state = commands.length ? "ready" : message ? "pending" : "empty";
if (commands.length) {
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>
<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>` : ""}
${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>
<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>`;
} else {
list.innerHTML = `<p>${escapeHtml(message || "本次请求未生成已验证的 CAN 控制指令")}</p>`;
list.innerHTML = `<p>${escapeHtml(message || "本次请求未解析出控制指令")}</p>`;
}
requestAnimationFrame(syncSidebarLogBaseline);
}
@@ -380,15 +450,18 @@
const items = commands.map((command) => ({
channel: command.startsWith("seat passenger ") ? "CAN0" : "CAN1",
command,
deliveryState: "not_submitted",
deliveryLabel: "未下发",
}));
count.textContent = String(items.length);
panel.dataset.state = "ready";
list.innerHTML = `<ol>${items.map((item) => `
<li data-can-channel="${item.channel}">
<li data-can-channel="${item.channel}" data-delivery-state="${item.deliveryState}">
<span>
<b class="v2-can-command-channel">${item.channel}</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>
<code>${escapeHtml(item.command)}</code>
</li>`).join("")}</ol>`;
@@ -1205,7 +1278,7 @@
: trace.length
? trace.slice(-4).map((item) => item.label || item.stage || item.event || "执行步骤")
: domains.length
? ["接收编排指令", `编排 ${domains.length} 个设备域`, "下发执行计划", "同步执行结果"]
? ["接收编排指令", `编排 ${domains.length} 个设备域`, "生成执行计划", "同步执行结果"]
: ["等待车机或网页端下发编排任务"];
timeline.innerHTML = stages.map((stage, index) => `
<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.
if (!validatedCommandsBelongToTask(task)) {
resetValidatedCommandContext();
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" });
renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" });
}
} else if (result) {
const commands = parsedCanCommands(result);
@@ -1451,7 +1524,7 @@
insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field");
const canCommandPanel = make("details", "v2-can-command-panel", `
<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>
<i data-lucide="chevron-down"></i>
</summary>
@@ -1550,7 +1623,7 @@
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
$("#requestForm")?.addEventListener("submit", () => {
resetValidatedCommandContext();
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" });
renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" });
});
window.addEventListener("cognitive-agent:validated-can-commands", (event) => {
const result = event.detail || {};
@@ -1558,7 +1631,7 @@
if (validatedCommandsVisible) rememberValidatedCommandContext(result);
else resetValidatedCommandContext();
renderParsedCanCommands(result, {
message: "已完成白名单校验,正在下发控制指令…",
message: "控制指令已解析,正在等待传输结果…",
});
});
window.addEventListener("cognitive-agent:result", (event) => {
@@ -1591,7 +1664,7 @@
});
window.addEventListener("cognitive-agent:request-complete", (event) => {
if (event.detail?.error && !validatedCommandsVisible) {
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" });
renderParsedCanCommands(null, { message: "本次请求未生成控制指令" });
}
if (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" />
<title>511工程-智能座舱</title>
<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="/self-extension.css?v=gscu-tcp-37" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-43" />
<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/three/0.147.0/three.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 type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=task-outcome-47" defer></script>
<script src="/self-extension.js?v=gscu-tcp-37" defer></script>
<script src="/console-v2.js?v=delivery-state-49" defer></script>
<script src="/self-extension.js?v=gscu-tcp-40" defer></script>
</head>
<body class="console-v2">
<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 {
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;
gap: 12px;
}
@@ -2455,9 +2455,9 @@ form > .v2-extension-primary {
.v2-gscu-command-catalog > header small { color: #7b8b89; font-size: 8px; }
#extensionGscuNamedCommands {
max-height: 180px;
max-height: min(42vh, 360px);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-top: 8px;
overflow: auto;
@@ -2475,7 +2475,66 @@ form > .v2-extension-primary {
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;
gap: 8px;
padding: 10px;
@@ -2537,9 +2596,28 @@ form > .v2-extension-primary {
#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 {
max-height: 170px;
margin: 12px 0 0;
margin: 0;
padding: 10px;
overflow: auto;
color: #d7f4ef;
@@ -2556,7 +2634,7 @@ form > .v2-extension-primary {
.v2-gscu-action-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.v2-gscu-advanced[open] { 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 {
@@ -3140,6 +3218,12 @@ form > .v2-extension-primary {
border-top: 1px solid #e3ecea;
}
.v2-nfc-existing > small {
color: #7c908d;
font-size: 9px;
line-height: 1.5;
}
.v2-nfc-existing > div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -3178,6 +3262,90 @@ form > .v2-extension-primary {
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 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -3282,6 +3450,12 @@ form > .v2-extension-primary {
margin-top: 15px;
}
.v2-nfc-recognized-actions > span {
align-self: center;
color: #718784;
font-size: 9px;
}
.v2-nfc-recognized-actions button {
min-height: 40px;
}
@@ -3349,7 +3523,8 @@ form > .v2-extension-primary {
}
.v2-nfc-create-grid,
.v2-nfc-capability-grid {
.v2-nfc-capability-grid,
.v2-nfc-command-list {
grid-template-columns: 1fr;
}
+394 -145
View File
@@ -476,14 +476,12 @@
saved.infraredCommands = Array.isArray(saved.infraredCommands)
? saved.infraredCommands.filter((item) => (
validSlot(item?.slot)
&& typeof item?.name === "string"
&& item.name.trim()
)).map((item) => ({
slot: Number(item.slot),
name: item.name.trim().slice(0, 48),
source: String(item.source || "tcp"),
learnedAt: item.learnedAt || null,
}))
&& (
String(item?.name || "").trim()
|| normalizedInfraredNames(item?.object_names ?? item?.objectNames).length
|| normalizedInfraredNames(item?.action_names ?? item?.actionNames).length
)
)).map(normalizedInfraredSlotRecord)
: [];
saved.selectedDeviceByType = saved.selectedDeviceByType || {};
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 };
@@ -724,7 +722,7 @@
<button class="v2-extension-primary" type="submit" id="extensionGscuTcpApply">应用并开始监听</button>
</form>
<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">
<button type="button" data-gscu-action="info">查询设备</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>
<div><button class="danger" type="button" data-gscu-action="erase">擦除全部红外码</button><button class="danger" type="button" data-gscu-action="reset">恢复出厂设置</button></div>
</details>
<section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong><small>槽位与名称保存在本机数据库,并按槽位号排列</small></header><div id="extensionGscuNamedCommands"></div></section>
<pre id="extensionGscuTcpResult">等待操作</pre>
<section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong><small>按设备 ID 分组;对象名称用于自然语言识别和调试,动作请包含结果值,如“切换为蓝色”</small></header><div id="extensionGscuNamedCommands"></div></section>
<details class="v2-gscu-result" id="extensionGscuTcpResultDetails"><summary>JSON 调试结果</summary><pre id="extensionGscuTcpResult">等待操作</pre></details>
</section>
</dialog>
<div class="v2-extension-toast-stack" id="extensionToastStack" aria-live="polite"></div>`;
@@ -817,7 +815,7 @@
$("b", status).textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE";
if (refreshControls) {
renderGscuNamedCommands();
syncGscuCommandNameFromSlot();
syncGscuCommandNamesFromSlot();
}
}
@@ -826,24 +824,143 @@
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) {
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 {
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"),
learnedAt: item.learned_at || item.learnedAt || 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) {
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)
.sort((left, right) => left.slot - right.slot);
reconcileDevicesAndCapabilitiesFromSlots();
saveState();
renderGscuNamedCommands();
syncGscuCommandNameFromSlot();
syncGscuCommandNamesFromSlot();
if ($("#extensionNfcDialog")) renderNfcDialog();
renderUnifiedExtensionView?.();
}
async function refreshInfraredSlotCatalog({ migrateLegacy = true } = {}) {
@@ -857,7 +974,16 @@
if (missing.length) {
await Promise.all(missing.map((item) => api(`gscu/slots/${item.slot}`, {
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");
}
@@ -866,43 +992,74 @@
return namedInfraredCommands();
}
async function upsertNamedInfraredCommand(slot, name, source = "tcp") {
async function upsertNamedInfraredCommand(
slot,
deviceId,
objectNames,
actionNames,
source = "tcp",
) {
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 (!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}`, {
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 existing = state.infraredCommands.find((item) => item.slot === normalizedSlot);
if (existing) Object.assign(existing, value);
else state.infraredCommands.push(value);
reconcileDevicesAndCapabilitiesFromSlots();
const boundDevice = state.devices.find((item) => item.id === value.deviceId);
saveState();
renderGscuNamedCommands();
if (boundDevice) {
renderAll();
await syncPeripheralCatalog(boundDevice);
}
return value;
}
function renderGscuNamedCommands() {
const container = $("#extensionGscuNamedCommands");
if (!container) return;
const commands = namedInfraredCommands();
container.innerHTML = commands.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("")
: "<p>学习成功后将在这里显示编号与指令名称。</p>";
const groups = infraredDeviceGroups();
container.innerHTML = groups.length
? 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>学习成功后将在这里按设备 ID 显示槽位、控制对象和执行动作。</p>";
}
function syncGscuCommandNameFromSlot() {
function syncGscuCommandNamesFromSlot() {
const slot = Number($("#extensionGscuSlot")?.value);
const command = namedInfraredCommands().find((item) => item.slot === slot);
if ($("#extensionGscuCommandName")) {
$("#extensionGscuCommandName").value = command?.name || "";
if ($("#extensionGscuDeviceId")) {
$("#extensionGscuDeviceId").value = command?.deviceId || "";
}
if ($("#extensionGscuObjectNames")) {
$("#extensionGscuObjectNames").value = command?.objectNames.join("、") || "";
}
if ($("#extensionGscuActionNames")) {
$("#extensionGscuActionNames").value = command?.actionNames.join("、") || "";
}
}
function openGscuTcpDialog(health = healthState || {}) {
renderGscuTcpDialog(health);
$("#extensionGscuTcpResultDetails")?.removeAttribute("open");
openDialog("extensionGscuTcpDialog");
void refreshInfraredSlotCatalog().catch((error) => {
showToast(`读取本地红外槽位数据库失败:${error.message}`, "error");
@@ -945,7 +1102,9 @@
async function runGscuTcpAction(button) {
const action = button.dataset.gscuAction;
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 = {};
if (["learn", "emit", "read", "write"].includes(action)) payload.slot = slot;
if (action === "write") payload.ir_data = $("#extensionGscuIrData").value.trim();
@@ -968,9 +1127,19 @@
payload.confirm = "ERASE_ALL";
}
if (action === "reset") payload.confirm = confirmation;
if (action === "learn" && !commandName) {
showToast("请输入该红外编号对应的指令名称。", "error");
$("#extensionGscuCommandName").focus();
if (action === "learn" && !deviceId) {
showToast("请输入该红外槽位对应的唯一设备 ID。", "error");
$("#extensionGscuDeviceId").focus();
return;
}
if (action === "learn" && !objectNames.length) {
showToast("请输入该红外槽位对应的控制对象名称。", "error");
$("#extensionGscuObjectNames").focus();
return;
}
if (action === "learn" && !actionNames.length) {
showToast("请输入该红外槽位对应的执行动作。", "error");
$("#extensionGscuActionNames").focus();
return;
}
button.disabled = true;
@@ -1006,7 +1175,7 @@
: "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。",
);
}
await upsertNamedInfraredCommand(slot, commandName, "tcp");
await upsertNamedInfraredCommand(slot, deviceId, objectNames, actionNames, "tcp");
result = {
...result,
learning_capture_confirmed: true,
@@ -1051,7 +1220,7 @@
if (!dialog || dialog.dataset.bound === "true") return;
dialog.dataset.bound = "true";
$("#extensionGscuTcpForm").addEventListener("submit", configureGscuTcp);
$("#extensionGscuSlot").addEventListener("change", syncGscuCommandNameFromSlot);
$("#extensionGscuSlot").addEventListener("change", syncGscuCommandNamesFromSlot);
dialog.addEventListener("click", (event) => {
if (event.target.closest('[data-close-extension-dialog="extensionGscuTcpDialog"]')) {
closeDialog("extensionGscuTcpDialog");
@@ -1068,7 +1237,9 @@
const command = namedInfraredCommands().find(
(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);
if (emitButton) void runGscuTcpAction(emitButton);
return;
@@ -1079,10 +1250,12 @@
save.disabled = true;
void upsertNamedInfraredCommand(
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",
).then((value) => {
showToast(`IR ${value.slot} 已保存为“${value.name}”。`, "success");
showToast(`IR ${value.slot} 的控制对象与执行动作已保存。`, "success");
}).catch((error) => {
showToast(error.message, "error");
}).finally(() => { save.disabled = false; });
@@ -1095,9 +1268,15 @@
if (!window.confirm(`确认删除“${command?.name || `IR ${slot}`}”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。`)) return;
remove.disabled = true;
void api(`gscu/slots/${slot}`, { method: "DELETE" }).then((result) => {
const affectedDeviceId = command?.deviceId;
state.infraredCommands = namedInfraredCommands().filter((item) => item.slot !== slot);
reconcileDevicesAndCapabilitiesFromSlots();
saveState();
renderGscuNamedCommands();
renderAll();
void syncPeripheralCatalog(
state.devices.find((item) => item.id === affectedDeviceId) || null,
);
showToast(result.message || `IR ${slot} 的本地记录已删除。`, "success");
}).catch((error) => {
remove.disabled = false;
@@ -1660,7 +1839,8 @@
const capability = state.capabilities.find(
(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");
button.disabled = true;
const relearning = Boolean(learning.draft.relearning);
@@ -1668,7 +1848,9 @@
try {
await upsertNamedInfraredCommand(
Number(learning.draft.slot),
capability.name,
device.id,
[device.name, ...(device.aliases || [])],
[capability.name, ...learning.draft.aliases],
"capability",
);
} catch (error) {
@@ -1715,28 +1897,123 @@
});
saveState();
renderAll();
void syncPeripheralCatalog();
showToast(`“${capability.name}”已恢复为待学习。`);
}
function peripheralCatalog() {
return learnedCapabilities().map((capability) => {
return configuredCapabilities().map((capability) => {
const device = state.devices.find((item) => item.id === capability.deviceId);
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 {
id: capability.id,
status: "learned",
device_id: device?.id,
device_name: device?.name,
device_aliases: device?.aliases || [],
object_names: objectNames,
device_type: device?.type,
card_uid: device?.cardUid || null,
toy_number: device?.toyNumber || null,
type_label: type.label,
action: capability.name,
aliases: capability.aliases || [],
action_names: actionNames,
risk: capability.risk || "low",
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() {
@@ -3046,6 +3323,7 @@
const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card;
const linkedDevice = linkedDeviceForCard(card?.uid);
const deviceGroups = infraredDeviceGroups().filter((group) => group.deviceId);
status.textContent = nfcReaderNote(reader);
badge.textContent = reader.online ? "READER READY" : "READER 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>`;
} else if (!card?.uid) {
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) {
const suggestedNumber = nextToyNumber();
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>
<div class="v2-nfc-create-grid">
<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>` : ""}`;
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>
${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>`}`;
} else {
const type = TOY_TYPES[linkedDevice.type] || TOY_TYPES.other;
const capabilities = deviceCapabilities(linkedDevice.id);
const learned = learnedCapabilities(linkedDevice.id);
const firstPending = capabilities.find((capability) => capability.status !== "learned");
const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
const capabilities = configuredCapabilities(linkedDevice.id);
const enabledCount = capabilities.filter((capability) => capability.enabled !== false).length;
const allEnabled = capabilities.length > 0 && enabledCount === capabilities.length;
stage.dataset.state = "recognized";
body.innerHTML = `<div class="v2-nfc-recognized-heading">
<span data-extension-type="${escapeHtml(linkedDevice.type || "other")}"><i data-lucide="${type.icon}"></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>
<span data-extension-type="other"><i data-lucide="cpu"></i></span>
<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>
</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-grid">${capabilities.map((capability) => {
const isLearned = learned.some((item) => item.id === capability.id);
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>`;
<div class="v2-nfc-capability-heading"><div><small>COMMAND AUTHORIZATION</small><strong>允许此 NFC 执行的命令</strong></div><span>${enabledCount}/${capabilities.length} 已选择</span></div>
${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>`}
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
@@ -3095,12 +3361,12 @@
const dialog = document.createElement("dialog");
dialog.className = "v2-extension-dialog v2-nfc-dialog";
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">
<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>
</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);
}
@@ -3131,43 +3397,18 @@
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() {
const deviceId = $("#extensionNfcExistingDevice")?.value;
reconcileDevicesAndCapabilitiesFromSlots();
const device = state.devices.find((item) => item.id === deviceId);
const cardUid = nfcSnapshot.card?.uid;
if (!device || !cardUid) return;
configuredCapabilities(device.id).forEach((capability) => {
capability.enabled = true;
});
bindCardToDevice(device, cardUid);
showToast(`NFC 已绑定到“${device.name}”。`, "success");
renderNfcDialog();
showToast(`NFC 已关联设备 ID“${device.id}”,全部命令已默认选中。`, "success");
}
function unbindCurrentNfc() {
@@ -3177,36 +3418,33 @@
saveState();
renderAll();
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 capability = capabilityId
? state.capabilities.find((item) => item.id === capabilityId)
: deviceCapabilities(device?.id).find((item) => item.status !== "learned");
if (!device || !capability) {
showToast("该玩具没有待学习动作。", "success");
return;
}
state.selectedDeviceByType[device.type] = device.id;
const capability = configuredCapabilities(device?.id).find(
(item) => item.id === capabilityId,
);
if (!device || !capability) return;
capability.enabled = enabled;
saveState();
openLearn(capability.id);
renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
}
async function emitNfcCapability(capabilityId) {
const capability = learnedCapabilities().find((item) => item.id === capabilityId);
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
if (capability.risk === "high" && !window.confirm(
`“${capability.name}”可能让玩具移动,请确认周围安全。`,
)) return;
try {
await sendGatewayCommand("emit", { slot: capability.slot });
showToast(`已向“${device.name}”发射“${capability.name}”。`, "success");
} catch (error) {
showToast(error.message, "error");
}
function updateAllNfcCapabilityAuthorizations(enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
configuredCapabilities(device.id).forEach((capability) => {
capability.enabled = enabled;
});
saveState();
renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
}
function applyNfcSnapshot(value, { announce = true } = {}) {
@@ -3232,10 +3470,10 @@
if (device) {
state.selectedDeviceByType[device.type] = device.id;
saveState();
if (announce) showToast(`已识别玩具“${device.name}”。`, "success");
if (announce) showToast(`已识别设备 ID“${device.id}”。`, "success");
void syncPeripheralCatalog(device);
} else if (announce) {
showToast("检测到新的玩具 NFC,请建立或绑定玩具档案。", "success");
showToast("检测到未绑定 NFC,请选择一个设备 ID。", "success");
}
}
renderNfcDialog();
@@ -3275,23 +3513,27 @@
function bindUnifiedNfcEvents(mount) {
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();
});
bindGscuTcpDialogEvents();
const dialog = $("#extensionNfcDialog");
dialog.addEventListener("click", (event) => {
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-unbind]")) unbindCurrentNfc();
const relearn = event.target.closest("[data-nfc-relearn-capability]");
if (relearn) continueNfcLearning(relearn.dataset.nfcRelearnCapability);
const learn = event.target.closest("[data-nfc-learn-capability]");
if (learn) continueNfcLearning(learn.dataset.nfcLearnCapability);
if (event.target.closest("[data-nfc-continue-learning]")) continueNfcLearning();
const run = event.target.closest("[data-nfc-run-capability]");
if (run) void emitNfcCapability(run.dataset.nfcRunCapability);
});
dialog.addEventListener("change", (event) => {
const capability = event.target.closest("[data-nfc-capability-enabled]");
if (capability) {
updateNfcCapabilityAuthorization(
capability.dataset.nfcCapabilityEnabled,
capability.checked,
);
return;
}
const selectAll = event.target.closest("[data-nfc-select-all]");
if (selectAll) updateAllNfcCapabilityAuthorizations(selectAll.checked);
});
$("#extensionContinueLearn").addEventListener("click", () => {
try {
@@ -3370,6 +3612,7 @@
? mergedCatalog.filter((item) => (
normalizeCardUid(item.card_uid) === sensedCardUid
&& item.status === "learned"
&& item.enabled !== false
))
: [];
const grouped = new Map();
@@ -3387,12 +3630,14 @@
const devices = [...grouped.values()];
const lastAction = snapshot?.extension?.last_action;
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 statusLabel = devices.length
? "已感应 NFC 外设"
: sensedCardUid
? "当前 NFC 暂无已学习能力"
? "当前 NFC 暂无已授权命令"
: nfcReader.online
? "等待 NFC 外设靠近"
: "NFC 读卡器离线";
@@ -3405,7 +3650,7 @@
: "红外链路检查中";
const gatewayNote = physicalConnected
? `${health.peer || "GSCU1B"} · 红外学习与发射链路已就绪`
: health.message || "红外学习器接入后可学习并发射玩具动作";
: health.message || "红外学习器接入后可学习并发射设备动作";
const visibleLastAction = devices.length ? lastAction : null;
const nfcState = !nfcReader.online
? "offline"
@@ -3415,28 +3660,28 @@
? "new-card"
: "waiting";
const nfcTitle = nfcDevice
? `${nfcDevice.toyNumber ? `#${nfcDevice.toyNumber} · ` : ""}${nfcDevice.name}`
? nfcDevice.id
: nfcCard?.uid
? "检测到新的玩具 NFC"
? "检测到未绑定 NFC"
: nfcReader.online
? "等待玩具靠近"
? "等待 NFC 卡靠近"
: "NFC 读卡器未连接";
const nfcNote = nfcDevice
? `${nfcCapabilities.length} 项已学习动作 · ${nfcCard.uid}`
? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
: nfcCard?.uid
? `${nfcCard.uid} · 点击建立或绑定玩具档案`
? `${nfcCard.uid} · 点击关联设备 ID`
: nfcReaderNote(nfcReader);
mount.dataset.hasDevices = String(devices.length > 0);
mount.innerHTML = `
<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>
<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>
<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>
<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>
<div class="v2-extension-gateway">
<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>
</article>`).join("") : `
<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>
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
@@ -3478,7 +3723,11 @@
render();
renderNfcDialog();
startNfcPolling();
void syncPeripheralCatalog();
void hydratePeripheralCatalog()
.catch((error) => {
console.warn("恢复后端红外能力目录失败,将保留浏览器本地目录。", error);
})
.finally(() => syncPeripheralCatalog());
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 RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -9,10 +9,10 @@ const APP_SHELL = [
"/app.js?v=dual-seat-107",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=sync-layout-41",
"/console-v2.js?v=task-outcome-47",
"/self-extension.css?v=gscu-tcp-37",
"/self-extension.js?v=gscu-tcp-37",
"/console-v2.css?v=delivery-state-43",
"/console-v2.js?v=delivery-state-49",
"/self-extension.css?v=gscu-tcp-39",
"/self-extension.js?v=gscu-tcp-40",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest",
+75 -13
View File
@@ -1507,7 +1507,11 @@ class SeatDemoService:
merged.update(
{
"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,
"preview_request_id": preview_request_id,
"supplement_of": preview_request_id,
@@ -1574,7 +1578,24 @@ class SeatDemoService:
merged["phase_execution_reports"] = {
"local_rule": deepcopy(preview.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:
merged["execution_report"] = deepcopy(preview.get("execution_report"))
successful = {"succeeded", "partially_succeeded", "no_action"}
@@ -2039,18 +2060,6 @@ class SeatDemoService:
"hardware mode refuses virtual or unverified vehicle actions: "
+ ", ".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"):
raise HardwareSafetyError(
"hardware mode requires at least one verified seat or vehicle CAN operation"
@@ -2192,6 +2201,7 @@ class SeatDemoService:
simulation_fault=simulation_fault,
vehicle_profile=vehicle_profile,
control_transport=control_transport,
approval_token=approval_token,
on_commands_validated=on_commands_validated,
)
@@ -2467,6 +2477,13 @@ class SeatDemoService:
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()
self._trace(
traces,
@@ -4162,6 +4179,34 @@ class SeatDemoService:
event["execution_report"] = None
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(
self,
utterance: str,
@@ -4178,6 +4223,7 @@ class SeatDemoService:
simulation_fault: str = "normal",
vehicle_profile: VehicleProfile | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
approval_token: str | None = None,
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Run isolated adapters under the selected physical-bus policy."""
@@ -4444,6 +4490,13 @@ class SeatDemoService:
self._emit_validated_commands(result, on_commands_validated)
if hardware_request:
self._authorize_compiled_hardware_delivery(
parsed,
approval_token,
control_transport,
)
self._trace(
traces,
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:
request_dir = self.artifact_root / result["request_id"]
(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 threading
import time
from copy import deepcopy
from http.cookies import CookieError, SimpleCookie
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -39,6 +40,7 @@ from cognitive_agent.application.self_extension import ( # noqa: E402
GatewayError,
SelfExtensionService,
active_peripheral_plan_catalog,
partition_active_peripheral_intent,
resolve_active_peripheral_command,
)
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
@@ -324,6 +326,26 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.SERVICE_UNAVAILABLE,
)
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":
self._handle_extension_gateway("info", {})
return
@@ -537,11 +559,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
else:
result = None
if path in {"/api/requests", "/api/requests/preview"}:
result = self._execute_active_peripheral_request(
utterance,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
result = self._execute_request_with_peripherals(
path,
common_arguments,
)
if result is None:
if path == "/api/requests/preview":
@@ -670,7 +690,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) -> dict[str, object] | None:
"""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()
plan_catalog = active_peripheral_plan_catalog(
utterance,
@@ -703,6 +726,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"utterance": utterance,
"peripherals": plan_catalog,
"vehicle_devices": [],
"provider": provider,
}
)
if plan_result.get("needs_confirmation"):
@@ -748,6 +772,465 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
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(
self,
path: str,
@@ -819,17 +1302,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
try:
result = None
if path in {"/api/requests", "/api/requests/preview"}:
result = self._execute_active_peripheral_request(
utterance,
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"
),
result = self._execute_request_with_peripherals(
path,
common_arguments,
on_commands_validated=on_commands_validated,
)
if result is None:
if path == "/api/requests/preview":
@@ -1052,7 +1528,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
catalog = payload.get("peripherals", payload.get("catalog", []))
if not isinstance(catalog, list) or len(catalog) > 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(
catalog=catalog,
action={
+595 -15
View File
@@ -33,6 +33,7 @@ class SeatWebApiTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.temporary_directory = tempfile.TemporaryDirectory()
cls.extension_temporary_directory = tempfile.TemporaryDirectory()
# Web regression tests must never inherit a developer's live bench
# enablement, especially when local debug mode intentionally skips the
# per-request approval token.
@@ -50,6 +51,13 @@ class SeatWebApiTests(unittest.TestCase):
cls.service = service
cls.original_service = SeatDemoHandler.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
SeatDemoHandler.auth = AuthRuntime(
AuthConfig(
@@ -79,7 +87,9 @@ class SeatWebApiTests(unittest.TestCase):
cls.server.server_close()
cls.thread.join(timeout=2)
SeatDemoHandler.service = cls.original_service
SeatDemoHandler.extension = cls.original_extension
SeatDemoHandler.auth = cls.original_auth
cls.extension_temporary_directory.cleanup()
cls.temporary_directory.cleanup()
def setUp(self) -> None:
@@ -341,19 +351,19 @@ operations:
) as response:
gesture_script = response.read().decode("utf-8")
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:
console_script = response.read().decode("utf-8")
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:
console_styles = response.read().decode("utf-8")
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:
extension_script = response.read().decode("utf-8")
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:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
@@ -388,13 +398,13 @@ operations:
self.assertIn('href="/styles.css?v=dual-seat-16"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
self.assertIn('src="/console-v2.js?v=task-outcome-47"', html)
self.assertIn('href="/console-v2.css?v=sync-layout-41"', html)
self.assertIn('src="/console-v2.js?v=delivery-state-49"', html)
self.assertIn('href="/console-v2.css?v=delivery-state-43"', html)
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
self.assertIn('src="/self-extension.js?v=gscu-tcp-37"', html)
self.assertIn('href="/self-extension.css?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-39"', html)
self.assertNotIn("self-calibration", html)
self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html)
@@ -639,7 +649,7 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', 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('"/assets/pets/self-extension-cat.png?v=3"', 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:
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:
with self.opener.open(
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:
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
try:
with tempfile.TemporaryDirectory() as directory:
@@ -1093,11 +1113,38 @@ operations:
with self.opener.open(request, timeout=3) as response:
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(
f"{self.base_url}/api/extension/gscu/slots", timeout=3
) as response:
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(
f"{self.base_url}/api/extension/gscu/slots/3",
@@ -1117,7 +1164,7 @@ operations:
with self.opener.open(erase_request, timeout=3) as response:
erased = json.loads(response.read().decode("utf-8"))
self.assertTrue(erased["catalog_cleared"])
self.assertEqual(erased["catalog_removed_count"], 1)
self.assertEqual(erased["catalog_removed_count"], 2)
with self.opener.open(
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:
original_extension = SeatDemoHandler.extension
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(
snapshot=lambda: {
"revision": 1,
@@ -1197,10 +1249,538 @@ operations:
self.assertEqual(operation["domain"], "self_extension")
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:
original_extension = SeatDemoHandler.extension
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.nfc = SimpleNamespace(
snapshot=lambda: {
+53 -16
View File
@@ -10,27 +10,33 @@ def source(name: str) -> str:
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")
styles = source("self-extension.css")
html = source("index.html")
assert 'data-open-nfc-learning' in script
assert 'data-open-nfc-binding' 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 'function createToyFromNfc()' 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 'N7WEB_REQUEST_IR_TOY_PROFILES' in script
assert 'IR_TOY_LEGACY_PROFILES' in script
assert 'value.legacy_profiles' in script
assert 'value.legacy_bridge_addresses' in script
assert 'cardUid: normalizeCardUid(cardUid)' 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 'device.cardUid = normalizedUid' in script
assert 'const targetSlot = relearning ? Number(capability.slot) : allocateSlot()' in script
assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' 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 'signalSource: "n7web"' in script
assert 'if (capability.signalSource === "n7web") return' in script
assert '建立档案并开始学习' in script
assert 'const activeCatalog = sensedCardUid' in script
assert 'normalizeCardUid(item.card_uid) === sensedCardUid' in script
assert '&& item.status === "learned"' 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 '将已绑定且已学习的 NFC 外设靠近读卡器后显示能力' in script
assert '将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令' in script
assert '.v2-nfc-inline' in styles
assert '.v2-nfc-dialog' in styles
assert '.v2-nfc-capability-grid' in styles
assert 'self-extension.js?v=gscu-tcp-37' in html
assert 'self-extension.css?v=gscu-tcp-37' in html
assert '.v2-nfc-command-list' in styles
assert '.v2-nfc-command-option' in styles
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:
@@ -59,7 +70,10 @@ def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
assert 'id="extensionGscuTcpHost"' 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 'data-open-gscu-tcp' 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 "确认擦除 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 "buttons.forEach((item) => { item.disabled = true; });" not in script
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 "#extensionGscuTcpResult" 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:
@@ -99,7 +122,21 @@ def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None
assert 'nfc = NfcReaderService()' 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 'SeatDemoHandler.nfc.start()' in server
assert 'SeatDemoHandler.nfc.close()' 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 "getParsedCanCommands: (result) => parsedCanCommands(result)" 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 'data-can-channel="${escapeHtml(item.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-list li code" 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 ".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",
"frame": "0x300 · 01 12 <SEQ_LO> <SEQ_HI> 10 0E 30 75",
"parseSource": "local_rule",
"deliveryState": "not_submitted",
"deliveryLabel": "未下发",
"deliveryTransport": "有线 CAN",
},
{
"channel": "CAN1",
"category": "空调温度",
"command": "ac temp driver 24",
"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:
app = source("app.js")
styles = source("styles.css")
+554
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import os
import socket
import sqlite3
import threading
import time
import unittest
@@ -22,6 +23,7 @@ from cognitive_agent.application.self_extension import (
active_peripheral_plan_catalog,
build_gateway_command,
create_gateway,
partition_active_peripheral_intent,
resolve_active_peripheral_command,
validate_slot,
)
@@ -78,6 +80,216 @@ class SelfExtensionTests(unittest.TestCase):
with self.assertRaises(GatewayError):
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:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
@@ -111,6 +323,127 @@ class SelfExtensionTests(unittest.TestCase):
[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:
with TemporaryDirectory() as directory:
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:
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:
service = SelfExtensionService(SimulationGateway())
@@ -502,6 +908,151 @@ class SelfExtensionTests(unittest.TestCase):
sleep.assert_any_call(5.0)
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:
service = SelfExtensionService(SimulationGateway())
catalog = [
@@ -645,6 +1196,9 @@ class SelfExtensionTests(unittest.TestCase):
) -> str:
self.assertEqual(peripherals[0]["id"], "peripheral_1")
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(
{
"summary": "切换红灯",
+148
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from unittest.mock import patch
from cognitive_agent.application.seat_service import SeatDemoService
from cognitive_agent.server import SeatDemoHandler
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 'item.parseSource === "direct_control" ? "设备控件"' 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 '.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 '"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",
}
]