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