添加红外扩展

This commit is contained in:
minghanchen
2026-08-28 17:27:43 +08:00
parent fd13d9f1ee
commit 6d75944c0f
11 changed files with 1602 additions and 75 deletions
+21 -5
View File
@@ -41,11 +41,27 @@ make dev-lan
其他设备访问启动日志打印的 `http://<LAN-IP>:8000`。页面与后端 API 由同一服务提供,无需单独配置前端地址。
自扩展接口统一位于 `/api/extension/*`,GSCU1B 默认以 TCP 模式监听
`0.0.0.0:4444`。可通过 `GSCU_TRANSPORT=simulation` 使用演示学习器,或通过
`GSCU_TRANSPORT=mqtt` 与 `GSCU_MQTT_*` 配置接入 MQTT。MQTT 依赖已包含在
核心环境中;NFC 串口依赖通过 `ir` 可选依赖安装。自扩展不再需要独立的
`8010` 前端服务。
自扩展接口统一位于 `/api/extension/*`。红外模块默认使用已验证的 GSCU1B TCP
模式:Linux 端在 `0.0.0.0:4444` 监听,GSCU1B 作为 TCP Client 主动接入。
“外设与扩展能力”中的“TCP 设置”可修改监听 IP、端口并立即重启监听,配置持久化到
`artifacts/tmp/gscu_gateway.json`;同一弹窗可执行查询、学习、取消、发射、读取、
写入、软重启、WiFi 配网锁、擦除、恢复出厂和原始 JSON 调试动作。模块配置的
服务器地址必须填写 Linux 的实际局域网 IP,不能填写 `0.0.0.0`。
TCP 发射、取消、写入和设置类动作在操作系统确认写入连接后立即返回“已下发”,
不会因设备不回 ACK 而阻塞前端。固件 1.1.5 的学习动作同样不主动返回 ACK,因此
前端在学习命令下发后自动读取目标槽位,以实际 `ir_data` 回读确认学习完成;设备查询
和读取槽位仍等待带 `messageId` 的实际回执。
学习时可为每个 `1–248` 红外槽位填写信号名称;槽位与名称持久化在本机
`artifacts/server/gscu_ir_catalog.sqlite3` 数据库中,并在 TCP 动作调试区按槽位号
升序显示为小卡片。浏览器中已有的旧名称会自动迁移到数据库。单卡片删除操作只删除
本地目录记录,不会擦除设备红外码;已验证的设备协议仅支持全部擦除。全部擦除会先
弹出不可恢复确认,设备命令提交成功后才同步清空本地槽位数据库。
如需回退 MQTT,可设置 `GSCU_TRANSPORT=mqtt`,通过 `GSCU_MQTT_*`、
`GSCU_COMMAND_TOPIC` 和 `GSCU_RESPONSE_TOPIC` 指定 Broker 与话题;通过
`GSCU_TRANSPORT=simulation` 使用演示学习器。MQTT 依赖已包含在核心环境中;
NFC 串口依赖通过 `ir` 可选依赖安装。自扩展不再需要独立的 `8010` 前端服务。
## NFC 玩具识别与红外学习
+2 -2
View File
@@ -11,7 +11,7 @@
<title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-16" />
<link rel="stylesheet" href="/console-v2.css?v=sync-layout-41" />
<link rel="stylesheet" href="/self-extension.css?v=nfc-toy-33" />
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-37" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
@@ -21,7 +21,7 @@
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=task-outcome-47" defer></script>
<script src="/self-extension.js?v=nfc-toy-33" defer></script>
<script src="/self-extension.js?v=gscu-tcp-37" defer></script>
</head>
<body class="console-v2">
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
+275 -3
View File
@@ -2227,7 +2227,7 @@ form > .v2-extension-primary {
.v2-extension-gateway {
min-width: 0;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
grid-template-columns: 34px minmax(0, 1fr) auto auto;
align-items: center;
gap: 9px;
padding: 8px;
@@ -2277,14 +2277,286 @@ form > .v2-extension-primary {
font: 800 7px "SFMono-Regular", Consolas, monospace;
}
.v2-extension-gateway > button {
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
color: #d7f7f3;
background: rgba(69, 181, 170, 0.14);
border: 1px solid rgba(107, 214, 203, 0.18);
border-radius: 7px;
font-size: 8px;
font-weight: 800;
cursor: pointer;
}
.v2-extension-gateway > button svg {
width: 12px;
height: 12px;
}
.v2-extension-device-list {
min-height: 0;
max-height: 124px;
overflow: auto;
}
.v2-extension-sync-mount[data-has-devices="true"] .v2-extension-gateway {
display: none;
/* GSCU1B TCP runtime controls ------------------------------------------- */
.v2-gscu-tcp-dialog {
width: min(720px, calc(100vw - 30px));
}
.v2-gscu-status {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
margin: 16px 20px 0;
padding: 12px;
color: #47605f;
background: #f4f8f7;
border: 1px solid #dce8e5;
border-radius: 12px;
}
.v2-gscu-status > svg {
width: 20px;
color: #758c89;
}
.v2-gscu-status > div {
min-width: 0;
display: grid;
gap: 3px;
}
.v2-gscu-status strong { font-size: 11px; }
.v2-gscu-status small { overflow: hidden; color: #748583; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.v2-gscu-status > b { color: #758c89; font: 800 8px "SFMono-Regular", Consolas, monospace; }
.v2-gscu-status[data-state="online"] { background: #edf9f5; border-color: #cceade; }
.v2-gscu-status[data-state="online"] > svg,
.v2-gscu-status[data-state="online"] > b { color: #218c6a; }
.v2-gscu-status[data-state="listening"] > svg,
.v2-gscu-status[data-state="listening"] > b { color: #a97625; }
.v2-gscu-config-form {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(130px, 0.6fr) auto;
align-items: end;
gap: 10px;
padding: 15px 20px;
}
.v2-gscu-config-form > label,
.v2-gscu-actions label {
display: grid;
gap: 5px;
color: #4c6263;
font-size: 9px;
font-weight: 800;
}
.v2-gscu-config-form label small {
min-height: 22px;
color: #879593;
font-size: 7px;
font-weight: 500;
line-height: 1.45;
}
.v2-gscu-config-form > .v2-extension-primary {
min-width: 135px;
margin: 0 0 22px;
}
.v2-gscu-actions {
margin: 0 20px 20px;
padding: 14px;
background: #f7f9f8;
border: 1px solid #e1e9e7;
border-radius: 13px;
}
.v2-gscu-actions > header {
display: grid;
grid-template-columns: minmax(0, 1fr) 110px minmax(150px, 0.7fr);
align-items: end;
gap: 12px;
}
.v2-gscu-actions > header > div { display: grid; gap: 3px; }
.v2-gscu-actions > header strong { font-size: 11px; }
.v2-gscu-actions > header small { color: #7b8b89; font-size: 8px; }
.v2-gscu-action-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
margin-top: 12px;
}
.v2-gscu-action-grid button,
.v2-gscu-advanced button {
min-height: 36px;
color: #315b58;
background: #fff;
border: 1px solid #d7e4e1;
border-radius: 9px;
font-size: 9px;
font-weight: 800;
cursor: pointer;
}
.v2-gscu-action-grid button:disabled,
.v2-gscu-advanced button:disabled { opacity: 0.5; cursor: wait; }
.v2-gscu-advanced {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e0e8e6;
}
.v2-gscu-advanced summary {
color: #607674;
font-size: 9px;
font-weight: 850;
cursor: pointer;
}
.v2-gscu-advanced[open] {
display: grid;
grid-template-columns: minmax(0, 1fr) 145px;
gap: 8px;
}
.v2-gscu-advanced[open] summary { grid-column: 1 / -1; }
.v2-gscu-advanced textarea { width: 100%; padding: 9px; resize: vertical; border: 1px solid #dbe4e2; border-radius: 9px; }
.v2-gscu-advanced > div { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
.v2-gscu-advanced button.danger { color: #a64639; border-color: #ebcfc9; }
.v2-gscu-command-catalog {
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid #e0e8e6;
}
.v2-gscu-command-catalog > header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.v2-gscu-command-catalog > header strong { font-size: 10px; }
.v2-gscu-command-catalog > header small { color: #7b8b89; font-size: 8px; }
#extensionGscuNamedCommands {
max-height: 180px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
overflow: auto;
}
#extensionGscuNamedCommands > p {
grid-column: 1 / -1;
margin: 0;
padding: 12px;
color: #82918f;
background: #fff;
border: 1px dashed #d7e2df;
border-radius: 9px;
font-size: 8px;
text-align: center;
}
#extensionGscuNamedCommands > article {
display: grid;
gap: 8px;
padding: 10px;
background: #fff;
border: 1px solid #dbe6e3;
border-radius: 10px;
}
#extensionGscuNamedCommands article > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
#extensionGscuNamedCommands article > header span,
#extensionGscuNamedCommands article label span {
color: #7b8b89;
font-size: 8px;
font-weight: 700;
}
#extensionGscuNamedCommands b {
color: #44716d;
font: 850 10px "SFMono-Regular", Consolas, monospace;
}
#extensionGscuNamedCommands label {
display: grid;
gap: 4px;
}
#extensionGscuNamedCommands input {
width: 100%;
min-width: 0;
padding: 8px;
border: 1px solid #dbe4e2;
border-radius: 8px;
font-size: 9px;
}
#extensionGscuNamedCommands footer {
display: grid;
grid-template-columns: 0.7fr 1fr 1.35fr;
gap: 5px;
}
#extensionGscuNamedCommands button {
min-height: 32px;
padding: 0 9px;
color: #315b58;
background: #fff;
border: 1px solid #d7e4e1;
border-radius: 8px;
font-size: 8px;
font-weight: 800;
cursor: pointer;
}
#extensionGscuNamedCommands button.danger { color: #a64639; border-color: #ebcfc9; }
#extensionGscuTcpResult {
max-height: 170px;
margin: 12px 0 0;
padding: 10px;
overflow: auto;
color: #d7f4ef;
background: #173331;
border-radius: 9px;
font: 8px/1.55 "SFMono-Regular", Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@media (max-width: 720px) {
.v2-gscu-config-form { grid-template-columns: 1fr; }
.v2-gscu-config-form > .v2-extension-primary { width: 100%; margin: 0; }
.v2-gscu-action-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.v2-gscu-advanced[open] { grid-template-columns: 1fr; }
.v2-gscu-actions > header { grid-template-columns: 1fr; }
#extensionGscuNamedCommands { grid-template-columns: 1fr; }
}
.v2-extension-synced-device {
+396 -8
View File
@@ -2,6 +2,7 @@
"use strict";
const STORAGE_KEY = "cognitive-agent.ir-toy-console.v5";
const SLOT_CATALOG_MIGRATION_KEY = "cognitive-agent.gscu-slot-catalog-migrated.v1";
const MODE_KEY = "cognitive-agent.extension-mode";
const CONVERSATION_KEY = "cognitive-agent.conversation-id";
const SLOT_MIN = 1;
@@ -451,6 +452,7 @@
history: [],
lastProtocol: null,
vehicleExecutions: {},
infraredCommands: [],
};
}
@@ -471,6 +473,18 @@
saved.vehicleExecutions = saved.vehicleExecutions && typeof saved.vehicleExecutions === "object"
? saved.vehicleExecutions
: {};
saved.infraredCommands = Array.isArray(saved.infraredCommands)
? saved.infraredCommands.filter((item) => (
validSlot(item?.slot)
&& typeof item?.name === "string"
&& item.name.trim()
)).map((item) => ({
slot: Number(item.slot),
name: item.name.trim().slice(0, 48),
source: String(item.source || "tcp"),
learnedAt: item.learnedAt || null,
}))
: [];
saved.selectedDeviceByType = saved.selectedDeviceByType || {};
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 };
// Learned capabilities are durable configuration; execution lamps, history,
@@ -701,6 +715,39 @@
<label><span>输入“清空”确认操作</span><input id="extensionResetConfirm" autocomplete="off" /></label>
<button class="v2-extension-danger" type="button" id="extensionConfirmReset" disabled>擦除并重新开始</button>
</dialog>
<dialog class="v2-extension-dialog v2-gscu-tcp-dialog" id="extensionGscuTcpDialog">
<header><div><span class="section-kicker">GSCU1B · TCP SERVER</span><h2>红外学习器 TCP 控制</h2><p>Linux 监听连接;GSCU1B 作为 TCP Client 接入。</p></div><button type="button" data-close-extension-dialog="extensionGscuTcpDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
<section class="v2-gscu-status" id="extensionGscuTcpStatus" data-state="checking"><i data-lucide="radio-tower"></i><div><strong>正在读取连接状态</strong><small>--</small></div><b>CHECK</b></section>
<form id="extensionGscuTcpForm" class="v2-gscu-config-form">
<label><span>本机监听 IP</span><input id="extensionGscuTcpHost" inputmode="decimal" maxlength="45" placeholder="0.0.0.0" required /><small>填写 0.0.0.0 可监听所有网卡;模块端应填写这台 Linux 的实际局域网 IP。</small></label>
<label><span>监听端口</span><input id="extensionGscuTcpPort" type="number" min="1" max="65535" value="4444" required /><small>必须与 GSCU1B 自定义 TCP 服务器端口一致。</small></label>
<button class="v2-extension-primary" type="submit" id="extensionGscuTcpApply">应用并开始监听</button>
</form>
<section class="v2-gscu-actions">
<header><div><strong>TCP 动作调试</strong><small>与 gscu1b_control.py tcp 使用相同 JSON 报文</small></div><label><span>红外槽位</span><input id="extensionGscuSlot" type="number" min="1" max="248" value="1" /></label><label><span>指令名称</span><input id="extensionGscuCommandName" maxlength="48" placeholder="例如:客厅空调开启" /></label></header>
<div class="v2-gscu-action-grid">
<button type="button" data-gscu-action="info">查询设备</button>
<button type="button" data-gscu-action="learn">开始学习</button>
<button type="button" data-gscu-action="cancel">取消学习</button>
<button type="button" data-gscu-action="emit">发射槽位</button>
<button type="button" data-gscu-action="read">读取槽位</button>
<button type="button" data-gscu-action="restart">软重启</button>
</div>
<details class="v2-gscu-advanced">
<summary>高级动作</summary>
<label><span>原始红外码</span><textarea id="extensionGscuIrData" rows="3" placeholder="粘贴 read 返回的 ir_data"></textarea></label>
<button type="button" data-gscu-action="write">写入当前槽位</button>
<label><span>WiFi 配网锁</span><select id="extensionGscuWifiLock"><option value="1">开启(1)</option><option value="0">关闭(0)</option></select></label>
<button type="button" data-gscu-action="wifi-lock">设置配网锁</button>
<label><span>原始 JSON</span><textarea id="extensionGscuRawCommand" rows="4" placeholder='例如 {"type":"info"}'></textarea></label>
<button type="button" data-gscu-action="raw">发送原始 JSON</button>
<label><span>恢复出厂确认</span><input id="extensionGscuDangerConfirm" autocomplete="off" placeholder="恢复出厂请输入 RESET_DEVICE" /></label>
<div><button class="danger" type="button" data-gscu-action="erase">擦除全部红外码</button><button class="danger" type="button" data-gscu-action="reset">恢复出厂设置</button></div>
</details>
<section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong><small>槽位与名称保存在本机数据库,并按槽位号排列</small></header><div id="extensionGscuNamedCommands"></div></section>
<pre id="extensionGscuTcpResult">等待操作</pre>
</section>
</dialog>
<div class="v2-extension-toast-stack" id="extensionToastStack" aria-live="polite"></div>`;
document.body.append(layer);
}
@@ -747,6 +794,319 @@
return payload;
}
function renderGscuTcpDialog(health = healthState || {}, refreshControls = true) {
const status = $("#extensionGscuTcpStatus");
if (!status) return;
const connected = Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
const listenHost = String(health.listen_host || "0.0.0.0");
const listenPort = Number(health.port || 4444);
if (refreshControls) {
$("#extensionGscuTcpHost").value = listenHost;
$("#extensionGscuTcpPort").value = Number.isInteger(listenPort) ? String(listenPort) : "4444";
}
status.dataset.state = connected ? "online" : listening ? "listening" : "offline";
$("strong", status).textContent = connected
? "GSCU1B TCP 已连接"
: listening
? "TCP 监听已就绪"
: "TCP 监听尚未就绪";
$("small", status).textContent = connected
? `${health.peer || "设备会话"} → ${health.listen || `${listenHost}:${listenPort}`}`
: health.message || `等待 GSCU1B 接入 ${listenHost}:${listenPort}`;
$("b", status).textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE";
if (refreshControls) {
renderGscuNamedCommands();
syncGscuCommandNameFromSlot();
}
}
function namedInfraredCommands() {
state.infraredCommands ||= [];
return [...state.infraredCommands].sort((left, right) => Number(left.slot) - Number(right.slot));
}
function normalizedInfraredSlotRecord(item) {
return {
slot: Number(item.slot),
name: String(item.name || "").trim().slice(0, 48),
source: String(item.source || "tcp"),
learnedAt: item.learned_at || item.learnedAt || null,
updatedAt: item.updated_at || item.updatedAt || null,
};
}
function replaceInfraredSlotCatalog(items) {
state.infraredCommands = (Array.isArray(items) ? items : [])
.filter((item) => validSlot(item?.slot) && String(item?.name || "").trim())
.map(normalizedInfraredSlotRecord)
.sort((left, right) => left.slot - right.slot);
saveState();
renderGscuNamedCommands();
syncGscuCommandNameFromSlot();
}
async function refreshInfraredSlotCatalog({ migrateLegacy = true } = {}) {
const shouldMigrate = migrateLegacy
&& localStorage.getItem(SLOT_CATALOG_MIGRATION_KEY) !== "done";
const legacy = shouldMigrate ? namedInfraredCommands() : [];
let result = await api("gscu/slots");
const serverSlots = Array.isArray(result.slots) ? result.slots : [];
const existingSlots = new Set(serverSlots.map((item) => Number(item.slot)));
const missing = legacy.filter((item) => !existingSlots.has(Number(item.slot)));
if (missing.length) {
await Promise.all(missing.map((item) => api(`gscu/slots/${item.slot}`, {
method: "PUT",
body: JSON.stringify({ name: item.name, source: item.source || "browser-migration" }),
})));
result = await api("gscu/slots");
}
if (shouldMigrate) localStorage.setItem(SLOT_CATALOG_MIGRATION_KEY, "done");
replaceInfraredSlotCatalog(result.slots);
return namedInfraredCommands();
}
async function upsertNamedInfraredCommand(slot, name, source = "tcp") {
const normalizedSlot = Number(slot);
const normalizedName = String(name || "").trim().slice(0, 48);
if (!validSlot(normalizedSlot)) throw new Error("红外槽位范围是 1–248。");
if (!normalizedName) throw new Error("学习红外指令前需要填写指令名称。");
const result = await api(`gscu/slots/${normalizedSlot}`, {
method: "PUT",
body: JSON.stringify({ name: normalizedName, source }),
});
const value = normalizedInfraredSlotRecord(result.slot);
const existing = state.infraredCommands.find((item) => item.slot === normalizedSlot);
if (existing) Object.assign(existing, value);
else state.infraredCommands.push(value);
saveState();
renderGscuNamedCommands();
return value;
}
function renderGscuNamedCommands() {
const container = $("#extensionGscuNamedCommands");
if (!container) return;
const commands = namedInfraredCommands();
container.innerHTML = commands.length
? commands.map((command) => `<article data-gscu-command-slot="${command.slot}"><header><span>红外槽位</span><b>IR ${String(command.slot).padStart(3, "0")}</b></header><label><span>信号名称</span><input maxlength="48" value="${escapeHtml(command.name)}" aria-label="IR ${command.slot} 信号名称" /></label><footer><button type="button" data-gscu-named-emit="${command.slot}">发射</button><button type="button" data-gscu-named-save="${command.slot}">保存名称</button><button class="danger" type="button" data-gscu-named-remove="${command.slot}">删除本地记录</button></footer></article>`).join("")
: "<p>学习成功后将在这里显示编号与指令名称。</p>";
}
function syncGscuCommandNameFromSlot() {
const slot = Number($("#extensionGscuSlot")?.value);
const command = namedInfraredCommands().find((item) => item.slot === slot);
if ($("#extensionGscuCommandName")) {
$("#extensionGscuCommandName").value = command?.name || "";
}
}
function openGscuTcpDialog(health = healthState || {}) {
renderGscuTcpDialog(health);
openDialog("extensionGscuTcpDialog");
void refreshInfraredSlotCatalog().catch((error) => {
showToast(`读取本地红外槽位数据库失败:${error.message}`, "error");
});
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function showGscuResult(value) {
const output = $("#extensionGscuTcpResult");
if (!output) return;
output.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
async function configureGscuTcp(event) {
event.preventDefault();
const button = $("#extensionGscuTcpApply");
button.disabled = true;
button.textContent = "正在重启监听…";
try {
const result = await api("gscu/config", {
method: "POST",
body: JSON.stringify({
listen_host: $("#extensionGscuTcpHost").value.trim(),
port: Number($("#extensionGscuTcpPort").value),
}),
});
healthState = result;
renderGscuTcpDialog(result, false);
showGscuResult(result);
showToast(`TCP 已切换到 ${result.listen || `${result.listen_host}:${result.port}`}`, "success");
} catch (error) {
showGscuResult(error.payload || error.message);
showToast(error.message, "error");
} finally {
button.disabled = false;
button.textContent = "应用并开始监听";
}
}
async function runGscuTcpAction(button) {
const action = button.dataset.gscuAction;
const slot = Number($("#extensionGscuSlot").value);
const commandName = $("#extensionGscuCommandName").value.trim();
const payload = {};
if (["learn", "emit", "read", "write"].includes(action)) payload.slot = slot;
if (action === "write") payload.ir_data = $("#extensionGscuIrData").value.trim();
if (action === "wifi-lock") payload.state = Number($("#extensionGscuWifiLock").value);
if (action === "raw") {
try {
payload.command = JSON.parse($("#extensionGscuRawCommand").value);
} catch (_error) {
showToast("原始指令不是有效 JSON。", "error");
return;
}
}
const confirmation = $("#extensionGscuDangerConfirm").value.trim();
if (action === "erase") {
const count = namedInfraredCommands().length;
const confirmed = window.confirm(
`确认擦除 GSCU1B 中的全部红外码吗?本机数据库中的 ${count} 条槽位记录也会被永久清空,操作无法恢复。`,
);
if (!confirmed) return;
payload.confirm = "ERASE_ALL";
}
if (action === "reset") payload.confirm = confirmation;
if (action === "learn" && !commandName) {
showToast("请输入该红外编号对应的指令名称。", "error");
$("#extensionGscuCommandName").focus();
return;
}
button.disabled = true;
showGscuResult(`正在执行 ${action}…`);
try {
const endpoint = action === "erase" ? "gscu/erase-all" : `gscu/${action}`;
let result = await api(endpoint, {
method: "POST",
body: JSON.stringify(payload),
});
if (action === "learn") {
showToast("学习指令已下发,请立即短按遥控器目标按键。", "success");
let readResult = null;
let learnedSignal = null;
let readError = null;
await delay(1200);
for (let attempt = 0; attempt < 6 && !learnedSignal; attempt += 1) {
try {
readResult = await api("gscu/read", {
method: "POST",
body: JSON.stringify({ slot }),
});
learnedSignal = infraredSignalFromResponse(readResult);
} catch (error) {
readError = error;
}
if (!learnedSignal && attempt < 5) await delay(1400);
}
if (!learnedSignal) {
throw new Error(
readError?.message
? `学习指令已下发,但槽位回读失败:${readError.message}`
: "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。",
);
}
await upsertNamedInfraredCommand(slot, commandName, "tcp");
result = {
...result,
learning_capture_confirmed: true,
verification: "slot_readback",
slot_readback: readResult,
};
}
if (action === "erase") {
replaceInfraredSlotCatalog([]);
state.capabilities.forEach((capability) => {
if (!validSlot(capability.slot)) return;
Object.assign(capability, {
slot: null,
status: "pending",
verified: false,
enabled: false,
fingerprint: null,
signalBytes: null,
learnedAt: null,
relearnedAt: null,
});
});
saveState();
renderAll();
void syncPeripheralCatalog();
}
showGscuResult(result);
showToast(
action === "learn" ? `IR ${slot} 学习完成,槽位回读已确认。` : `${action} 指令处理完成。`,
"success",
);
} catch (error) {
showGscuResult(error.payload || error.message);
showToast(error.message, "error");
} finally {
button.disabled = false;
}
}
function bindGscuTcpDialogEvents() {
const dialog = $("#extensionGscuTcpDialog");
if (!dialog || dialog.dataset.bound === "true") return;
dialog.dataset.bound = "true";
$("#extensionGscuTcpForm").addEventListener("submit", configureGscuTcp);
$("#extensionGscuSlot").addEventListener("change", syncGscuCommandNameFromSlot);
dialog.addEventListener("click", (event) => {
if (event.target.closest('[data-close-extension-dialog="extensionGscuTcpDialog"]')) {
closeDialog("extensionGscuTcpDialog");
return;
}
const action = event.target.closest("[data-gscu-action]");
if (action) {
void runGscuTcpAction(action);
return;
}
const emit = event.target.closest("[data-gscu-named-emit]");
if (emit) {
$("#extensionGscuSlot").value = emit.dataset.gscuNamedEmit;
const command = namedInfraredCommands().find(
(item) => item.slot === Number(emit.dataset.gscuNamedEmit),
);
$("#extensionGscuCommandName").value = command?.name || "";
const emitButton = $("[data-gscu-action=\"emit\"]", dialog);
if (emitButton) void runGscuTcpAction(emitButton);
return;
}
const save = event.target.closest("[data-gscu-named-save]");
if (save) {
const card = save.closest("[data-gscu-command-slot]");
save.disabled = true;
void upsertNamedInfraredCommand(
Number(save.dataset.gscuNamedSave),
$("input", card).value,
"tcp",
).then((value) => {
showToast(`IR ${value.slot} 已保存为“${value.name}”。`, "success");
}).catch((error) => {
showToast(error.message, "error");
}).finally(() => { save.disabled = false; });
return;
}
const remove = event.target.closest("[data-gscu-named-remove]");
if (remove) {
const slot = Number(remove.dataset.gscuNamedRemove);
const command = namedInfraredCommands().find((item) => item.slot === slot);
if (!window.confirm(`确认删除“${command?.name || `IR ${slot}`}”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。`)) return;
remove.disabled = true;
void api(`gscu/slots/${slot}`, { method: "DELETE" }).then((result) => {
state.infraredCommands = namedInfraredCommands().filter((item) => item.slot !== slot);
saveState();
renderGscuNamedCommands();
showToast(result.message || `IR ${slot} 的本地记录已删除。`, "success");
}).catch((error) => {
remove.disabled = false;
showToast(error.message, "error");
});
}
});
}
function sharedConversationId() {
const fromConsole = window.CognitiveAgentConsole?.getConversationId?.();
if (typeof fromConsole === "string" && fromConsole.startsWith("conv-")) {
@@ -1224,22 +1584,22 @@
);
let learnedSignal = null;
let readError = null;
await delay(700);
for (let attempt = 0; attempt < 3 && !learnedSignal; attempt += 1) {
await delay(1200);
for (let attempt = 0; attempt < 6 && !learnedSignal; attempt += 1) {
try {
const readResult = await sendGatewayCommand("read", { slot: learning.draft.slot });
learnedSignal = infraredSignalFromResponse(readResult);
} catch (error) {
readError = error;
}
if (!learnedSignal && attempt < 2) await delay(900);
if (!learnedSignal && attempt < 5) await delay(1400);
}
learnedSignal ||= infraredSignalFromResponse(learnResult);
if (!learnedSignal) {
throw new Error(
readError?.message
? `学习后无法回读槽位:${readError.message}`
: "学习回执成功,但物理槽位回读为空;请对准学习器并短按遥控器按键。",
: "学习指令已下发,但物理槽位回读为空;请对准学习器并短按遥控器按键。",
);
}
learning.fingerprint = await infraredFingerprint(learnedSignal);
@@ -1295,14 +1655,27 @@
}
}
function confirmLearning() {
async function confirmLearning() {
if (!learning.tested || !learning.draft) return;
const capability = state.capabilities.find(
(item) => item.id === learning.draft.capabilityId,
);
if (!capability) return;
const button = $("#extensionConfirmLearn");
button.disabled = true;
const relearning = Boolean(learning.draft.relearning);
const learnedAt = new Date().toISOString();
try {
await upsertNamedInfraredCommand(
Number(learning.draft.slot),
capability.name,
"capability",
);
} catch (error) {
button.disabled = false;
showToast(`保存红外槽位数据库失败:${error.message}`, "error");
return false;
}
Object.assign(capability, {
aliases: [...new Set([capability.name, ...learning.draft.aliases])],
risk: learning.draft.risk,
@@ -1327,6 +1700,7 @@
"success",
);
learning = freshLearningSession();
return true;
}
function forgetCapability(capabilityId) {
@@ -1796,6 +2170,7 @@
try {
await sendGatewayCommand("erase", { confirm: "ERASE_ALL" });
state.capabilities = state.devices.flatMap((device) => seedPresets(device));
state.infraredCommands = [];
state.history = [];
state.sessionStartedAt = new Date().toISOString();
saveState();
@@ -2276,6 +2651,7 @@
}
function bindEvents() {
bindGscuTcpDialogEvents();
$$('[data-extension-mode]').forEach((button) => {
button.addEventListener("click", () => setMode(button.dataset.extensionMode));
});
@@ -2330,7 +2706,7 @@
$("#extensionStartLearn").addEventListener("click", startLearning);
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
$("#extensionTestEmit").addEventListener("click", testLearning);
$("#extensionConfirmLearn").addEventListener("click", confirmLearning);
$("#extensionConfirmLearn").addEventListener("click", () => { void confirmLearning(); });
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
if (learning.phase === "listening") {
event.preventDefault();
@@ -2900,7 +3276,9 @@
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
if (event.target.closest("[data-open-nfc-learning]")) openNfcDialog();
if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
});
bindGscuTcpDialogEvents();
const dialog = $("#extensionNfcDialog");
dialog.addEventListener("click", (event) => {
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
@@ -2926,9 +3304,10 @@
$("#extensionStartLearn").addEventListener("click", startLearning);
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
$("#extensionTestEmit").addEventListener("click", testLearning);
$("#extensionConfirmLearn").addEventListener("click", () => {
confirmLearning();
$("#extensionConfirmLearn").addEventListener("click", async () => {
if (await confirmLearning()) {
void syncPeripheralCatalog(linkedDeviceForCard(nfcSnapshot.card?.uid));
}
});
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
if (learning.phase === "listening") {
@@ -2953,6 +3332,8 @@
const render = () => {
const health = snapshot?.extension_health || {};
healthState = health;
if ($("#extensionGscuTcpDialog")?.open) renderGscuTcpDialog(health, false);
const embeddedNfc = health.nfc;
if (nfcSnapshot.revision < 0 && embeddedNfc) {
nfcSnapshot = {
@@ -3061,6 +3442,7 @@
<span><i data-lucide="usb"></i></span>
<div><strong>${gatewayLabel}</strong><small>${escapeHtml(gatewayNote)}</small></div>
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
<button type="button" data-open-gscu-tcp><i data-lucide="settings-2"></i><span>TCP 设置</span></button>
</div>
<div class="v2-extension-device-list">
${devices.length ? devices.map((device) => `
@@ -3090,6 +3472,9 @@
buildDialogs();
buildUnifiedNfcDialog();
bindUnifiedNfcEvents(mount);
void refreshInfraredSlotCatalog().catch((error) => {
console.warn("读取本地红外槽位数据库失败。", error);
});
render();
renderNfcDialog();
startNfcPolling();
@@ -3109,6 +3494,9 @@
setMode(activeMode);
if (document.body.dataset.module === "self-extension") refreshHealth();
startHealthPolling();
void refreshInfraredSlotCatalog().catch((error) => {
console.warn("读取本地红外槽位数据库失败。", error);
});
void migrateLegacyProfiles();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
+3 -3
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v210";
const CACHE_VERSION = "cognitive-agent-pad-v214";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -11,8 +11,8 @@ const APP_SHELL = [
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=sync-layout-41",
"/console-v2.js?v=task-outcome-47",
"/self-extension.css?v=nfc-toy-33",
"/self-extension.js?v=nfc-toy-33",
"/self-extension.css?v=gscu-tcp-37",
"/self-extension.js?v=gscu-tcp-37",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
"/manifest.webmanifest",
@@ -23,3 +23,20 @@ COGNITIVE_AGENT_VEHICLE_MQTT_PORT=1883
COGNITIVE_AGENT_VEHICLE_MQTT_CONDITION_TOPIC=n7/n7-headunit/can/state
COGNITIVE_AGENT_VEHICLE_MQTT_CONTROL_TOPIC=n7/n7-headunit/can/command
COGNITIVE_AGENT_VEHICLE_MQTT_QOS=1
# GSCU1B infrared module. Linux acts as the TCP server and the module connects
# as a TCP client. The same listen address and port can also be changed from
# the Web Console and are persisted in artifacts/tmp/gscu_gateway.json.
GSCU_TRANSPORT=tcp
GSCU_TCP_LISTEN_HOST=0.0.0.0
GSCU_TCP_PORT=4444
# Optional; defaults to artifacts/server/gscu_ir_catalog.sqlite3.
# GSCU_IR_CATALOG_DB_PATH=/home/orin/project511/Cognitive_Agent/artifacts/server/gscu_ir_catalog.sqlite3
# MQTT remains available as an optional fallback, but is not the default.
# GSCU_TRANSPORT=mqtt
# GSCU_MQTT_HOST=127.0.0.1
# GSCU_MQTT_PORT=1883
# GSCU_COMMAND_TOPIC=topic/from_n7
# GSCU_RESPONSE_TOPIC=topic/from_infrared
# GSCU_MQTT_QOS=0
+395 -32
View File
@@ -7,10 +7,14 @@ import os
import queue
import re
import socket
import sqlite3
import ssl
import threading
import time
import uuid
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
@@ -24,7 +28,6 @@ from cognitive_agent.infrastructure.llm.openai_responses import (
OpenAIIntentInterpreter,
)
SLOT_MIN = 1
SLOT_MAX = 248
MAX_CATALOG_ITEMS = 248
@@ -32,6 +35,21 @@ MAX_PLAN_STEPS = 12
MIN_PLAN_DELAY_MS = 1
MAX_PLAN_DELAY_MS = 30_000
MAX_TOTAL_PLAN_DELAY_MS = 60_000
DEFAULT_GSCU_MQTT_HOST = "127.0.0.1"
DEFAULT_GSCU_MQTT_PORT = 1883
DEFAULT_GSCU_COMMAND_TOPIC = "topic/from_n7"
DEFAULT_GSCU_RESPONSE_TOPIC = "topic/from_infrared"
DEFAULT_GSCU_TCP_LISTEN_HOST = "0.0.0.0"
DEFAULT_GSCU_TCP_PORT = 4444
DEFAULT_GSCU_RUNTIME_CONFIG_PATH = (
Path(__file__).resolve().parents[3] / "artifacts" / "tmp" / "gscu_gateway.json"
)
DEFAULT_GSCU_IR_CATALOG_DB_PATH = (
Path(__file__).resolve().parents[3]
/ "artifacts"
/ "server"
/ "gscu_ir_catalog.sqlite3"
)
_ACTION_SYNONYM_GROUPS = (
@@ -64,6 +82,178 @@ def _env_bool(name: str, default: bool = False) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}
def validate_tcp_listen_host(value: Any) -> str:
host = str(value or "").strip()
if not host or len(host) > 45:
raise GatewayError("TCP 监听 IP 不能为空。")
try:
socket.inet_pton(socket.AF_INET, host)
except OSError as exc:
raise GatewayError("TCP 监听地址必须是有效的 IPv4 地址,例如 0.0.0.0。") from exc
return host
def validate_tcp_port(value: Any) -> int:
try:
port = int(value)
except (TypeError, ValueError) as exc:
raise GatewayError("TCP 监听端口必须是整数。") from exc
if not 1 <= port <= 65535:
raise GatewayError("TCP 监听端口范围是 1–65535。")
return port
def _runtime_gateway_config_path() -> Path:
configured = os.getenv("GSCU_RUNTIME_CONFIG_PATH", "").strip()
return Path(configured).expanduser() if configured else DEFAULT_GSCU_RUNTIME_CONFIG_PATH
def _load_runtime_gateway_config() -> dict[str, Any]:
path = _runtime_gateway_config_path()
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, OSError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _save_runtime_gateway_config(payload: dict[str, Any]) -> None:
path = _runtime_gateway_config_path()
temporary = path.with_suffix(f"{path.suffix}.tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
temporary.replace(path)
except OSError as exc:
raise GatewayError(f"保存红外 TCP 配置失败:{exc}") from exc
def _infrared_catalog_db_path() -> Path:
configured = os.getenv("GSCU_IR_CATALOG_DB_PATH", "").strip()
return Path(configured).expanduser() if configured else DEFAULT_GSCU_IR_CATALOG_DB_PATH
class InfraredSlotCatalog:
"""Durable metadata for infrared slots learned by GSCU1B."""
def __init__(self, database_path: str | Path | None = None) -> None:
self.database_path = Path(database_path or _infrared_catalog_db_path()).expanduser()
self._lock = threading.RLock()
self._initialize()
@staticmethod
def _timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds")
@staticmethod
def _record(row: sqlite3.Row) -> dict[str, Any]:
return {
"slot": int(row["slot"]),
"name": str(row["name"]),
"source": str(row["source"]),
"learned_at": str(row["learned_at"]),
"updated_at": str(row["updated_at"]),
}
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=5.0)
connection.row_factory = sqlite3.Row
return connection
def _initialize(self) -> None:
try:
self.database_path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as connection, connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS infrared_slots (
slot INTEGER PRIMARY KEY CHECK(slot BETWEEN 1 AND 248),
name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 48),
source TEXT NOT NULL DEFAULT 'tcp',
learned_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
except (OSError, sqlite3.Error) as exc:
raise GatewayError(f"初始化红外槽位数据库失败:{exc}") from exc
def list_slots(self) -> list[dict[str, Any]]:
with self._lock:
try:
with closing(self._connect()) as connection:
rows = connection.execute(
"SELECT slot, name, source, learned_at, updated_at "
"FROM infrared_slots ORDER BY slot ASC"
).fetchall()
except sqlite3.Error as exc:
raise GatewayError(f"读取红外槽位数据库失败:{exc}") from exc
return [self._record(row) for row in rows]
def upsert(self, slot: Any, name: Any, source: Any = "tcp") -> dict[str, Any]:
normalized_slot = validate_slot(slot)
normalized_name = str(name or "").strip()
if not normalized_name:
raise GatewayError("红外信号名称不能为空。")
if len(normalized_name) > 48:
raise GatewayError("红外信号名称不能超过 48 个字符。")
normalized_source = str(source or "tcp").strip()[:32] or "tcp"
now = self._timestamp()
with self._lock:
try:
with closing(self._connect()) as connection, connection:
connection.execute(
"""
INSERT INTO infrared_slots (
slot, name, source, learned_at, updated_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(slot) DO UPDATE SET
name = excluded.name,
source = excluded.source,
updated_at = excluded.updated_at
""",
(normalized_slot, normalized_name, normalized_source, now, now),
)
row = connection.execute(
"SELECT slot, name, source, learned_at, updated_at "
"FROM infrared_slots WHERE slot = ?",
(normalized_slot,),
).fetchone()
except sqlite3.Error as exc:
raise GatewayError(f"保存红外槽位失败:{exc}") from exc
assert row is not None
return self._record(row)
def delete(self, slot: Any) -> bool:
normalized_slot = validate_slot(slot)
with self._lock:
try:
with closing(self._connect()) as connection, connection:
cursor = connection.execute(
"DELETE FROM infrared_slots WHERE slot = ?", (normalized_slot,)
)
return cursor.rowcount > 0
except sqlite3.Error as exc:
raise GatewayError(f"删除红外槽位记录失败:{exc}") from exc
def clear(self) -> int:
with self._lock:
try:
with closing(self._connect()) as connection, connection:
count = int(
connection.execute(
"SELECT COUNT(*) FROM infrared_slots"
).fetchone()[0]
)
connection.execute("DELETE FROM infrared_slots")
return count
except sqlite3.Error as exc:
raise GatewayError(f"清空红外槽位数据库失败:{exc}") from exc
def _vehicle_catalog_commands(vehicle_devices: list[dict[str, Any]]) -> tuple[str, ...]:
"""Return the exact, UI-declared dual-channel vehicle commands."""
@@ -424,29 +614,60 @@ def active_peripheral_plan_catalog(
def build_gateway_command(action: str, payload: dict[str, Any]) -> dict[str, Any]:
request_id = _message_id()
slot_mode = _env_text("GSCU_SLOT_FIELD_MODE", "data").lower()
if slot_mode not in {"data", "top_level"}:
raise GatewayError("GSCU_SLOT_FIELD_MODE 只能是 data 或 top_level。")
if action == "info":
return {"type": "info", "messageId": request_id}
if action == "cancel":
return {"type": "infrared", "action": "learnCancel", "messageId": request_id}
return {"action": "learnCancel", "type": "infrared"}
if action == "erase":
if payload.get("confirm") != "ERASE_ALL":
raise GatewayError("全量擦除需要明确确认 ERASE_ALL。")
return {"type": "infrared", "action": "erase", "messageId": request_id}
return {"action": "erase", "type": "infrared"}
if action in {"learn", "emit", "read"}:
slot = validate_slot(payload.get("slot"))
command: dict[str, Any] = {
"type": "infrared",
"action": "ir_read" if action == "read" else action,
"messageId": request_id,
"type": "infrared",
}
if action == "read" or slot_mode == "data":
if action == "read":
command["messageId"] = request_id
command["data"] = {"no": slot}
else:
command["no"] = slot
return command
if action == "write":
slot = validate_slot(payload.get("slot"))
ir_data = str(payload.get("ir_data", "")).strip()
if not ir_data or len(ir_data) > 240_000:
raise GatewayError("写入红外码不能为空且不能超过 240000 个字符。")
return {
"action": "ir_write",
"ir_data": ir_data,
"messageId": request_id,
"no": slot,
"type": "infrared",
}
if action == "restart":
return {"system": "restart", "type": "setting"}
if action == "reset":
if payload.get("confirm") != "RESET_DEVICE":
raise GatewayError("恢复出厂设置需要明确确认 RESET_DEVICE。")
return {"system": "reset", "type": "setting"}
if action == "wifi_lock":
try:
state = int(payload.get("state"))
except (TypeError, ValueError) as exc:
raise GatewayError("WiFi 配网锁状态只能是 0 或 1。") from exc
if state not in {0, 1}:
raise GatewayError("WiFi 配网锁状态只能是 0 或 1。")
return {"wifiLock": state, "type": "setting"}
if action == "raw":
raw = payload.get("command")
if not isinstance(raw, dict) or not raw:
raise GatewayError("原始指令必须是非空 JSON 对象。")
encoded = json.dumps(raw, ensure_ascii=False)
if len(encoded.encode("utf-8")) > 240_000:
raise GatewayError("原始指令不能超过 240000 字节。")
command = dict(raw)
command.setdefault("messageId", request_id)
return command
raise GatewayError(f"不支持的设备操作:{action}")
@@ -457,6 +678,16 @@ def _response_matches(payload: Any, expected_message_id: str) -> bool:
return payload.get("messageId") in {None, expected_message_id}
def _tcp_command_requires_response(command: dict[str, Any]) -> bool:
"""Only block for GSCU1B operations whose result is consumed by the UI."""
# Firmware 1.1.5 accepts ``learn`` and writes the captured signal to the
# requested slot, but does not publish a spontaneous learning ACK. The
# verified CLI therefore treats learn like a submitted command. The UI
# confirms completion separately with ``ir_read``.
return command.get("type") == "info" or command.get("action") == "ir_read"
class BaseGateway:
transport = "unknown"
@@ -512,9 +743,21 @@ class UnavailableGateway(BaseGateway):
class TcpGateway(BaseGateway):
transport = "tcp"
def __init__(self) -> None:
self.listen_host = _env_text("GSCU_TCP_LISTEN_HOST", "0.0.0.0")
self.port = int(_env_text("GSCU_TCP_PORT", "4444"))
def __init__(
self,
listen_host: str | None = None,
port: int | None = None,
) -> None:
self.listen_host = validate_tcp_listen_host(
listen_host
if listen_host is not None
else _env_text("GSCU_TCP_LISTEN_HOST", DEFAULT_GSCU_TCP_LISTEN_HOST)
)
self.port = validate_tcp_port(
port
if port is not None
else _env_text("GSCU_TCP_PORT", str(DEFAULT_GSCU_TCP_PORT))
)
self.bind_retry_interval = max(
0.05,
float(_env_text("GSCU_TCP_BIND_RETRY_SECONDS", "1.0")),
@@ -577,6 +820,8 @@ class TcpGateway(BaseGateway):
"listening": listening,
"peer_connected": connected,
"listen": f"{self.listen_host}:{self.port}",
"listen_host": self.listen_host,
"port": self.port,
"peer": (
f"{peer_address[0]}:{peer_address[1]}" if peer_address else None
),
@@ -785,6 +1030,15 @@ class TcpGateway(BaseGateway):
except OSError:
pass
raise GatewayError(f"GSCU1B TCP 发送失败:{exc}") from exc
if not _tcp_command_requires_response(command):
return {
"success": True,
"message": "COMMAND_SUBMITTED",
"delivery_status": "submitted",
"transport_submitted": True,
"gateway_acknowledged": False,
"physical_state_confirmed": False,
}
return self._wait_for_response(command, timeout)
def _wait_for_response(self, command: dict[str, Any], timeout: float) -> dict[str, Any]:
@@ -797,9 +1051,7 @@ class TcpGateway(BaseGateway):
break
if _response_matches(payload, expected_id):
return payload if isinstance(payload, dict) else {"message": payload}
if command.get("action") == "learn":
raise GatewayError("学习指令已发送,但未在超时前收到红外捕获回执。")
return {
result = {
"success": True,
"message": "COMMAND_SENT_WITHOUT_DEVICE_ACK",
"gateway_acknowledged": False,
@@ -816,12 +1068,20 @@ class MqttGateway(BaseGateway):
except ImportError as exc:
raise GatewayError("MQTT 模式需要安装 paho-mqtt。") from exc
self.mqtt = mqtt
self.host = _env_text("GSCU_MQTT_HOST", "mqtt.geek-smart.cn")
self.port = int(_env_text("GSCU_MQTT_PORT", "1883"))
self.command_topic = os.getenv("GSCU_COMMAND_TOPIC", "").strip()
self.response_topic = os.getenv("GSCU_RESPONSE_TOPIC", "").strip()
if not self.command_topic or not self.response_topic:
raise GatewayError("MQTT 模式必须配置 GSCU_COMMAND_TOPIC 和 GSCU_RESPONSE_TOPIC。")
shared_host = _env_text(
"COGNITIVE_AGENT_VEHICLE_MQTT_HOST", DEFAULT_GSCU_MQTT_HOST
)
shared_port = _env_text(
"COGNITIVE_AGENT_VEHICLE_MQTT_PORT", str(DEFAULT_GSCU_MQTT_PORT)
)
self.host = _env_text("GSCU_MQTT_HOST", shared_host)
self.port = int(_env_text("GSCU_MQTT_PORT", shared_port))
self.command_topic = _env_text(
"GSCU_COMMAND_TOPIC", DEFAULT_GSCU_COMMAND_TOPIC
)
self.response_topic = _env_text(
"GSCU_RESPONSE_TOPIC", DEFAULT_GSCU_RESPONSE_TOPIC
)
self.qos = int(_env_text("GSCU_MQTT_QOS", "0"))
self._connected = threading.Event()
self._responses: queue.Queue[dict[str, Any] | str] = queue.Queue()
@@ -855,6 +1115,9 @@ class MqttGateway(BaseGateway):
"transport": self.transport,
"connected": self._connected.is_set(),
"broker": f"{self.host}:{self.port}",
"command_topic": self.command_topic,
"response_topic": self.response_topic,
"qos": self.qos,
}
def _on_connect(
@@ -930,11 +1193,29 @@ class MqttGateway(BaseGateway):
def create_gateway() -> BaseGateway:
transport = _env_text("GSCU_TRANSPORT", "tcp").lower()
runtime_config = _load_runtime_gateway_config()
transport = _env_text(
"GSCU_TRANSPORT", str(runtime_config.get("transport") or "tcp")
).lower()
if transport == "simulation":
return SimulationGateway()
if transport == "tcp":
return TcpGateway()
runtime_host = runtime_config.get("listen_host")
runtime_port = runtime_config.get("port")
listen_host = (
os.getenv("GSCU_TCP_LISTEN_HOST", "").strip()
or runtime_host
or DEFAULT_GSCU_TCP_LISTEN_HOST
)
port = (
os.getenv("GSCU_TCP_PORT", "").strip()
or runtime_port
or DEFAULT_GSCU_TCP_PORT
)
try:
return TcpGateway(str(listen_host), validate_tcp_port(port))
except GatewayError as exc:
return UnavailableGateway("tcp", str(exc))
if transport == "mqtt":
try:
return MqttGateway()
@@ -946,8 +1227,16 @@ def create_gateway() -> BaseGateway:
class SelfExtensionService:
"""Own the GSCU1B gateway and hybrid GPT/vehicle orchestration boundary."""
def __init__(self, gateway: BaseGateway | None = None) -> None:
def __init__(
self,
gateway: BaseGateway | None = None,
*,
catalog_db_path: str | Path | None = None,
) -> None:
self.gateway = gateway or create_gateway()
self.infrared_catalog = InfraredSlotCatalog(catalog_db_path)
self._gateway_lock = threading.RLock()
self._started = False
self.command_timeout = float(_env_text("GSCU_COMMAND_TIMEOUT", "15"))
self.openai_base_url = _env_text(
"OPENAI_BASE_URL", "https://api.openai.com/v1"
@@ -966,15 +1255,21 @@ class SelfExtensionService:
self.vehicle_id = _env_text("COGNITIVE_AGENT_VEHICLE_ID", "denza_n7")
def start(self) -> None:
with self._gateway_lock:
self._started = True
self.gateway.start()
def close(self) -> None:
with self._gateway_lock:
self._started = False
self.gateway.close()
def health(self) -> dict[str, Any]:
with self._gateway_lock:
gateway_status = self.gateway.status()
return {
"success": True,
**self.gateway.status(),
**gateway_status,
"gpt": {
"configured": bool(os.getenv("OPENAI_API_KEY", "").strip()),
"model": self.openai_model,
@@ -988,6 +1283,60 @@ class SelfExtensionService:
},
}
def configure_tcp_gateway(self, payload: dict[str, Any]) -> dict[str, Any]:
listen_host = validate_tcp_listen_host(payload.get("listen_host"))
port = validate_tcp_port(payload.get("port"))
config = {
"transport": "tcp",
"listen_host": listen_host,
"port": port,
}
_save_runtime_gateway_config(config)
replacement = TcpGateway(listen_host, port)
with self._gateway_lock:
previous = self.gateway
previous.close()
self.gateway = replacement
if self._started:
replacement.start()
return self.health()
def list_infrared_slots(self) -> dict[str, Any]:
slots = self.infrared_catalog.list_slots()
return {"success": True, "slots": slots, "count": len(slots)}
def save_infrared_slot(self, slot: Any, payload: dict[str, Any]) -> dict[str, Any]:
record = self.infrared_catalog.upsert(
slot,
payload.get("name"),
payload.get("source", "tcp"),
)
return {"success": True, "slot": record}
def delete_infrared_slot(self, slot: Any) -> dict[str, Any]:
removed = self.infrared_catalog.delete(slot)
return {
"success": True,
"removed": removed,
"slot": validate_slot(slot),
"device_signal_erased": False,
"message": (
"本地槽位记录已删除;GSCU1B 中的红外码未被擦除。"
if removed
else "本地没有该槽位记录;GSCU1B 中的红外码未被擦除。"
),
}
def erase_all_infrared(self, payload: dict[str, Any]) -> dict[str, Any]:
result = self.execute_gateway("erase", payload)
removed = self.infrared_catalog.clear()
return {
**result,
"catalog_cleared": True,
"catalog_removed_count": removed,
"message": "设备全部擦除命令已提交,本地红外槽位目录已同步清空。",
}
def execute_gateway(self, action: str, payload: dict[str, Any]) -> dict[str, Any]:
repeat = int(payload.get("repeat", 1))
interval_ms = int(payload.get("interval_ms", 250))
@@ -997,25 +1346,39 @@ class SelfExtensionService:
raise GatewayError("重复发射间隔范围是 100-5000 ms。")
command = build_gateway_command(action, payload)
responses: list[dict[str, Any]] = []
with self._gateway_lock:
gateway = self.gateway
for index in range(repeat):
current = command if index == 0 else {**command, "messageId": _message_id()}
response = self.gateway.send(current, self.command_timeout)
current = command
if index > 0 and "messageId" in command:
current = {**command, "messageId": _message_id()}
response = gateway.send(current, self.command_timeout)
if response.get("success") is False:
raise GatewayError(str(response.get("message") or "设备返回失败。"))
responses.append(response)
if index + 1 < repeat:
time.sleep(interval_ms / 1000.0)
return {
learn_submitted = action == "learn" and any(
item.get("delivery_status") == "submitted" for item in responses
)
result = {
"success": True,
"transport": self.gateway.transport,
"transport": gateway.transport,
"request": command,
"device_response": responses[0] if repeat == 1 else responses,
"gateway_acknowledged": all(
item.get("gateway_acknowledged", True) for item in responses
),
"physical_state_confirmed": False,
"message": "学习器指令处理完成;玩具物理状态需要独立确认。",
"message": (
"学习指令已提交;设备固件不主动返回学习 ACK,请通过槽位回读确认。"
if learn_submitted
else "学习器指令处理完成;玩具物理状态需要独立确认。"
),
}
if learn_submitted:
result["learning_capture_confirmed"] = False
return result
def execute_peripheral_request(
self,
+71 -1
View File
@@ -315,6 +315,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path == "/api/extension/nfc/status":
self._json({"success": True, **self.nfc.snapshot()})
return
if path == "/api/extension/gscu/slots":
try:
self._json(self.extension.list_infrared_slots())
except GatewayError as exc:
self._json(
{"success": False, "error": "catalog_unavailable", "message": str(exc)},
HTTPStatus.SERVICE_UNAVAILABLE,
)
return
if path == "/api/extension/gscu/info":
self._handle_extension_gateway("info", {})
return
@@ -902,11 +911,44 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
detail=str(exc),
)
def do_PUT(self) -> None: # noqa: N802 - stdlib handler API
path = unquote(urlparse(self.path).path)
if not self._authorize(path):
return
match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
if match is None:
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return
try:
payload = self._read_json_body(max_bytes=4096)
self._json(
self.extension.save_infrared_slot(match.group("slot"), payload),
HTTPStatus.OK,
)
except (GatewayError, ValueError, json.JSONDecodeError) as exc:
self._json(
{"success": False, "error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
parsed_url = urlparse(self.path)
path = unquote(parsed_url.path)
if not self._authorize(path):
return
slot_match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
if slot_match is not None:
try:
self._json(
self.extension.delete_infrared_slot(slot_match.group("slot")),
HTTPStatus.OK,
)
except GatewayError as exc:
self._json(
{"success": False, "error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
return
prefix = "/api/mode-profiles/"
if not path.startswith(prefix):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
@@ -966,18 +1008,46 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
def _handle_extension_post(self, path: str) -> None:
action_map = {
"/api/extension/gscu/info": "info",
"/api/extension/gscu/learn": "learn",
"/api/extension/gscu/cancel": "cancel",
"/api/extension/gscu/emit": "emit",
"/api/extension/gscu/read": "read",
"/api/extension/gscu/erase": "erase",
"/api/extension/gscu/write": "write",
"/api/extension/gscu/restart": "restart",
"/api/extension/gscu/reset": "reset",
"/api/extension/gscu/wifi-lock": "wifi_lock",
"/api/extension/gscu/raw": "raw",
}
try:
payload = self._read_json_body(max_bytes=256 * 1024)
if path in {
"/api/extension/gscu/erase",
"/api/extension/gscu/erase-all",
}:
result = self.extension.erase_all_infrared(dict(payload))
self.vehicle_display_sync.update_extension(
action={"type": "erase_all"},
result=result,
)
self._json(result)
return
action = action_map.get(path)
if action:
self._handle_extension_gateway(action, payload)
return
if path == "/api/extension/gscu/config":
result = self.extension.configure_tcp_gateway(dict(payload))
self.vehicle_display_sync.update_extension(
action={
"type": "gateway_config",
"listen_host": result.get("listen_host"),
"port": result.get("port"),
},
result=result,
)
self._json(result)
return
if path == "/api/extension/display-sync":
catalog = payload.get("peripherals", payload.get("catalog", []))
if not isinstance(catalog, list) or len(catalog) > 248:
+95 -5
View File
@@ -349,11 +349,11 @@ operations:
) as response:
console_styles = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.js?v=nfc-toy-33", timeout=3
f"{self.base_url}/self-extension.js?v=gscu-tcp-37", timeout=3
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.css?v=nfc-toy-33", timeout=3
f"{self.base_url}/self-extension.css?v=gscu-tcp-37", timeout=3
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
@@ -393,8 +393,8 @@ operations:
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
self.assertIn('src="/self-extension.js?v=nfc-toy-33"', html)
self.assertIn('href="/self-extension.css?v=nfc-toy-33"', html)
self.assertIn('src="/self-extension.js?v=gscu-tcp-37"', html)
self.assertIn('href="/self-extension.css?v=gscu-tcp-37"', html)
self.assertNotIn("self-calibration", html)
self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html)
@@ -639,7 +639,7 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v210"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v214"', service_worker)
self.assertIn('"/app.js?v=dual-seat-107"', service_worker)
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
self.assertIn(".then(() => self.skipWaiting())", service_worker)
@@ -1037,6 +1037,96 @@ operations:
self.assertEqual(result["transport"], "simulation")
self.assertFalse(result["physical_state_confirmed"])
def test_self_extension_tcp_configuration_endpoint_updates_runtime(self) -> None:
original_extension = SeatDemoHandler.extension
extension = SelfExtensionService(SimulationGateway())
SeatDemoHandler.extension = extension
try:
with tempfile.TemporaryDirectory() as directory:
with patch.dict(
os.environ,
{"GSCU_RUNTIME_CONFIG_PATH": f"{directory}/gateway.json"},
clear=False,
):
request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/config",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"listen_host": "127.0.0.1", "port": 45444}
).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
result = json.loads(response.read().decode("utf-8"))
persisted = json.loads(
Path(directory, "gateway.json").read_text(encoding="utf-8")
)
finally:
extension.close()
SeatDemoHandler.extension = original_extension
self.assertTrue(result["success"])
self.assertEqual(result["transport"], "tcp")
self.assertEqual(result["listen_host"], "127.0.0.1")
self.assertEqual(result["port"], 45444)
self.assertEqual(
persisted,
{"transport": "tcp", "listen_host": "127.0.0.1", "port": 45444},
)
def test_infrared_slot_catalog_api_and_confirmed_erase_all(self) -> None:
original_extension = SeatDemoHandler.extension
with tempfile.TemporaryDirectory() as directory:
extension = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
)
SeatDemoHandler.extension = extension
try:
for slot, name in ((12, "电视开关"), (3, "空调制冷")):
request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/slots/{slot}",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps({"name": name, "source": "tcp"}).encode("utf-8"),
)
with self.opener.open(request, timeout=3) as response:
self.assertEqual(response.status, 200)
with self.opener.open(
f"{self.base_url}/api/extension/gscu/slots", timeout=3
) as response:
catalog = json.loads(response.read().decode("utf-8"))
self.assertEqual([item["slot"] for item in catalog["slots"]], [3, 12])
delete_request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/slots/3",
method="DELETE",
)
with self.opener.open(delete_request, timeout=3) as response:
deleted = json.loads(response.read().decode("utf-8"))
self.assertTrue(deleted["removed"])
self.assertFalse(deleted["device_signal_erased"])
erase_request = urllib.request.Request(
f"{self.base_url}/api/extension/gscu/erase-all",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps({"confirm": "ERASE_ALL"}).encode("utf-8"),
)
with self.opener.open(erase_request, timeout=3) as response:
erased = json.loads(response.read().decode("utf-8"))
self.assertTrue(erased["catalog_cleared"])
self.assertEqual(erased["catalog_removed_count"], 1)
with self.opener.open(
f"{self.base_url}/api/extension/gscu/slots", timeout=3
) as response:
empty_catalog = json.loads(response.read().decode("utf-8"))
self.assertEqual(empty_catalog["slots"], [])
finally:
SeatDemoHandler.extension = original_extension
def test_main_request_routes_live_nfc_peripheral_to_gscu(self) -> None:
original_extension = SeatDemoHandler.extension
original_nfc = SeatDemoHandler.nfc
+44 -3
View File
@@ -35,7 +35,7 @@ def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None:
assert 'sendGatewayCommand("read", { slot: learning.draft.slot })' in script
assert 'function infraredSignalFromResponse(value)' in script
assert 'function infraredFingerprint(signal)' in script
assert '学习回执成功,但物理槽位回读为空' in script
assert '学习指令已下发,但物理槽位回读为空' in script
assert 'signalSource: "n7web"' in script
assert 'if (capability.signalSource === "n7web") return' in script
assert '建立档案并开始学习' in script
@@ -49,8 +49,49 @@ def test_unified_console_exposes_nfc_discovery_and_learning_flow() -> None:
assert '.v2-nfc-inline' in styles
assert '.v2-nfc-dialog' in styles
assert '.v2-nfc-capability-grid' in styles
assert 'self-extension.js?v=nfc-toy-33' in html
assert 'self-extension.css?v=nfc-toy-33' in html
assert 'self-extension.js?v=gscu-tcp-37' in html
assert 'self-extension.css?v=gscu-tcp-37' in html
def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
script = source("self-extension.js")
styles = source("self-extension.css")
assert 'id="extensionGscuTcpHost"' in script
assert 'id="extensionGscuTcpPort"' in script
assert 'id="extensionGscuCommandName"' in script
assert 'id="extensionGscuNamedCommands"' in script
assert 'data-open-gscu-tcp' in script
assert 'api("gscu/config"' in script
assert 'api("gscu/slots")' in script
assert 'method: "PUT"' in script
assert 'method: "DELETE"' in script
assert '"gscu/erase-all"' in script
assert 'verification: "slot_readback"' in script
assert "学习指令已下发,但槽位回读失败" in script
assert "确认擦除 GSCU1B 中的全部红外码吗" in script
assert "GSCU1B 设备中的该红外码不会被擦除" in script
assert "function upsertNamedInfraredCommand(slot, name" in script
assert "button.disabled = true;" in script
assert "buttons.forEach((item) => { item.disabled = true; });" not in script
for action in (
"info",
"learn",
"cancel",
"emit",
"read",
"write",
"restart",
"wifi-lock",
"erase",
"reset",
"raw",
):
assert f'data-gscu-action="{action}"' in script
assert ".v2-gscu-tcp-dialog" in styles
assert ".v2-gscu-config-form" in styles
assert "#extensionGscuTcpResult" in styles
assert "#extensionGscuNamedCommands" in styles
def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None:
+273 -3
View File
@@ -1,23 +1,27 @@
from __future__ import annotations
from collections.abc import Callable
from copy import deepcopy
import json
import os
import socket
import threading
import time
import unittest
from collections.abc import Callable
from copy import deepcopy
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import MagicMock, Mock, patch
from cognitive_agent.application.self_extension import (
ExtensionIntegrationError,
GatewayError,
MqttGateway,
SelfExtensionService,
SimulationGateway,
TcpGateway,
active_peripheral_plan_catalog,
build_gateway_command,
create_gateway,
resolve_active_peripheral_command,
validate_slot,
)
@@ -39,7 +43,34 @@ class SelfExtensionTests(unittest.TestCase):
self.assertEqual(command["type"], "infrared")
self.assertEqual(command["action"], "learn")
self.assertEqual(command["data"], {"no": 17})
self.assertTrue(str(command["messageId"]).startswith("bridge-"))
self.assertNotIn("messageId", command)
self.assertEqual(
build_gateway_command("emit", {"slot": 17}),
{"action": "emit", "data": {"no": 17}, "type": "infrared"},
)
self.assertEqual(
build_gateway_command("cancel", {}),
{"action": "learnCancel", "type": "infrared"},
)
read_command = build_gateway_command("read", {"slot": 17})
self.assertEqual(read_command["action"], "ir_read")
self.assertEqual(read_command["data"], {"no": 17})
self.assertTrue(str(read_command["messageId"]).startswith("bridge-"))
write_command = build_gateway_command(
"write", {"slot": 17, "ir_data": "sample-infrared-data"}
)
self.assertEqual(write_command["action"], "ir_write")
self.assertEqual(write_command["no"], 17)
self.assertEqual(write_command["ir_data"], "sample-infrared-data")
self.assertEqual(
build_gateway_command("restart", {}),
{"system": "restart", "type": "setting"},
)
self.assertEqual(
build_gateway_command("wifi_lock", {"state": 1}),
{"wifiLock": 1, "type": "setting"},
)
self.assertEqual(validate_slot("248"), 248)
with self.assertRaises(GatewayError):
@@ -47,6 +78,245 @@ class SelfExtensionTests(unittest.TestCase):
with self.assertRaises(GatewayError):
build_gateway_command("erase", {"confirm": "wrong"})
def test_infrared_slot_catalog_is_durable_sorted_and_device_safe(self) -> None:
with TemporaryDirectory() as directory:
database_path = Path(directory, "infrared-slots.sqlite3")
service = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
slot_12 = service.save_infrared_slot(
12, {"name": "电视开关", "source": "tcp"}
)["slot"]
service.save_infrared_slot(3, {"name": "空调制冷", "source": "tcp"})
renamed = service.save_infrared_slot(
12, {"name": "电视电源", "source": "tcp"}
)["slot"]
self.assertEqual(
[item["slot"] for item in service.list_infrared_slots()["slots"]],
[3, 12],
)
self.assertEqual(renamed["name"], "电视电源")
self.assertEqual(renamed["learned_at"], slot_12["learned_at"])
reloaded = SelfExtensionService(
SimulationGateway(), catalog_db_path=database_path
)
self.assertEqual(reloaded.list_infrared_slots()["count"], 2)
deleted = reloaded.delete_infrared_slot(3)
self.assertTrue(deleted["removed"])
self.assertFalse(deleted["device_signal_erased"])
self.assertEqual(
[item["slot"] for item in reloaded.list_infrared_slots()["slots"]],
[12],
)
def test_erase_all_clears_catalog_only_after_valid_device_submission(self) -> None:
with TemporaryDirectory() as directory:
service = SelfExtensionService(
SimulationGateway(),
catalog_db_path=Path(directory, "infrared-slots.sqlite3"),
)
service.save_infrared_slot(8, {"name": "玩具启动"})
with self.assertRaisesRegex(GatewayError, "ERASE_ALL"):
service.erase_all_infrared({"confirm": "wrong"})
self.assertEqual(service.list_infrared_slots()["count"], 1)
result = service.erase_all_infrared({"confirm": "ERASE_ALL"})
self.assertTrue(result["catalog_cleared"])
self.assertEqual(result["catalog_removed_count"], 1)
self.assertEqual(service.list_infrared_slots()["count"], 0)
def test_mqtt_gateway_defaults_to_local_infrared_topics(self) -> None:
mqtt_variables = {
"GSCU_MQTT_HOST",
"GSCU_MQTT_PORT",
"GSCU_COMMAND_TOPIC",
"GSCU_RESPONSE_TOPIC",
"COGNITIVE_AGENT_VEHICLE_MQTT_HOST",
"COGNITIVE_AGENT_VEHICLE_MQTT_PORT",
}
environment = {
key: value for key, value in os.environ.items() if key not in mqtt_variables
}
with patch.dict(os.environ, environment, clear=True):
gateway = MqttGateway()
self.assertEqual(gateway.host, "127.0.0.1")
self.assertEqual(gateway.port, 1883)
self.assertEqual(gateway.command_topic, "topic/from_n7")
self.assertEqual(gateway.response_topic, "topic/from_infrared")
self.assertEqual(
gateway.status(),
{
"transport": "mqtt",
"connected": False,
"broker": "127.0.0.1:1883",
"command_topic": "topic/from_n7",
"response_topic": "topic/from_infrared",
"qos": 0,
},
)
def test_default_gateway_transport_is_tcp(self) -> None:
with TemporaryDirectory() as directory:
with patch.dict(
os.environ,
{"GSCU_RUNTIME_CONFIG_PATH": f"{directory}/gateway.json"},
clear=True,
):
gateway = create_gateway()
self.assertIsInstance(gateway, TcpGateway)
self.assertEqual(gateway.listen_host, "0.0.0.0")
self.assertEqual(gateway.port, 4444)
def test_tcp_gateway_runtime_configuration_is_persisted_and_reloaded(self) -> None:
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
probe.close()
service = SelfExtensionService(SimulationGateway())
with TemporaryDirectory() as directory:
config_path = f"{directory}/gateway.json"
with patch.dict(
os.environ,
{"GSCU_RUNTIME_CONFIG_PATH": config_path},
clear=True,
):
service.start()
try:
result = service.configure_tcp_gateway(
{"listen_host": "127.0.0.1", "port": port}
)
self.assertEqual(result["transport"], "tcp")
self.assertEqual(result["listen_host"], "127.0.0.1")
self.assertEqual(result["port"], port)
self.assertTrue(
self._wait_until(lambda: service.health().get("listening")),
service.health(),
)
saved = json.loads(Path(config_path).read_text(encoding="utf-8"))
self.assertEqual(
saved,
{
"transport": "tcp",
"listen_host": "127.0.0.1",
"port": port,
},
)
reloaded = create_gateway()
self.assertIsInstance(reloaded, TcpGateway)
self.assertEqual(reloaded.listen_host, "127.0.0.1")
self.assertEqual(reloaded.port, port)
finally:
service.close()
def test_tcp_gateway_runtime_configuration_rejects_invalid_values(self) -> None:
service = SelfExtensionService(SimulationGateway())
with self.assertRaisesRegex(GatewayError, "IPv4"):
service.configure_tcp_gateway({"listen_host": "not-an-ip", "port": 4444})
with self.assertRaisesRegex(GatewayError, "1–65535"):
service.configure_tcp_gateway({"listen_host": "0.0.0.0", "port": 70000})
def test_tcp_gateway_submits_verified_learning_protocol_without_ack(self) -> None:
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
probe.close()
gateway = TcpGateway("127.0.0.1", port)
service = SelfExtensionService(gateway)
service.command_timeout = 2
received: list[dict[str, object]] = []
device_finished = threading.Event()
def run_device() -> None:
connection = socket.create_connection(("127.0.0.1", port), timeout=2)
try:
payload = b""
while b"\n" not in payload:
payload += connection.recv(4096)
received.append(json.loads(payload.split(b"\n", 1)[0]))
time.sleep(0.3)
finally:
connection.close()
device_finished.set()
service.start()
try:
self.assertTrue(self._wait_until(lambda: gateway.status()["listening"]))
device_thread = threading.Thread(target=run_device, daemon=True)
device_thread.start()
self.assertTrue(self._wait_until(lambda: gateway.status()["connected"]))
started = time.monotonic()
result = service.execute_gateway("learn", {"slot": 23})
elapsed = time.monotonic() - started
self.assertTrue(device_finished.wait(timeout=2))
self.assertEqual(
received,
[
{
"action": "learn",
"data": {"no": 23},
"type": "infrared",
}
],
)
self.assertLess(elapsed, 0.5)
self.assertEqual(result["device_response"]["delivery_status"], "submitted")
self.assertTrue(result["device_response"]["transport_submitted"])
self.assertFalse(result["learning_capture_confirmed"])
self.assertIn("槽位回读确认", result["message"])
finally:
service.close()
def test_tcp_gateway_returns_immediately_for_command_without_device_ack(
self,
) -> None:
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
probe.bind(("127.0.0.1", 0))
port = int(probe.getsockname()[1])
probe.close()
gateway = TcpGateway("127.0.0.1", port)
service = SelfExtensionService(gateway)
service.command_timeout = 2
received = threading.Event()
def run_device() -> None:
connection = socket.create_connection(("127.0.0.1", port), timeout=2)
try:
payload = b""
while b"\n" not in payload:
payload += connection.recv(4096)
command = json.loads(payload.split(b"\n", 1)[0])
self.assertEqual(
command,
{"action": "emit", "data": {"no": 9}, "type": "infrared"},
)
received.set()
time.sleep(0.3)
finally:
connection.close()
service.start()
try:
self.assertTrue(self._wait_until(lambda: gateway.status()["listening"]))
device_thread = threading.Thread(target=run_device, daemon=True)
device_thread.start()
self.assertTrue(self._wait_until(lambda: gateway.status()["connected"]))
started = time.monotonic()
result = service.execute_gateway("emit", {"slot": 9})
elapsed = time.monotonic() - started
self.assertTrue(received.wait(timeout=1))
self.assertLess(elapsed, 0.5)
self.assertEqual(
result["device_response"]["delivery_status"], "submitted"
)
self.assertTrue(result["device_response"]["transport_submitted"])
self.assertFalse(result["gateway_acknowledged"])
finally:
service.close()
def test_simulation_gateway_executes_without_physical_confirmation(self) -> None:
service = SelfExtensionService(SimulationGateway())
result = service.execute_gateway("emit", {"slot": 3, "repeat": 2})