diff --git a/apps/web-console/README.md b/apps/web-console/README.md index c016749..dfe5c1d 100755 --- a/apps/web-console/README.md +++ b/apps/web-console/README.md @@ -41,11 +41,27 @@ make dev-lan 其他设备访问启动日志打印的 `http://: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 玩具识别与红外学习 diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 776a8ce..072194d 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -11,7 +11,7 @@ 511工程-智能座舱 - + @@ -21,7 +21,7 @@ - +
diff --git a/apps/web-console/self-extension.css b/apps/web-console/self-extension.css index 33c3efa..1b3899c 100644 --- a/apps/web-console/self-extension.css +++ b/apps/web-console/self-extension.css @@ -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 { diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js index 34090ae..533e418 100644 --- a/apps/web-console/self-extension.js +++ b/apps/web-console/self-extension.js @@ -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 @@ + +
GSCU1B · TCP SERVER

红外学习器 TCP 控制

Linux 监听连接;GSCU1B 作为 TCP Client 接入。

+
正在读取连接状态--
CHECK
+
+ + + +
+
+
TCP 动作调试与 gscu1b_control.py tcp 使用相同 JSON 报文
+
+ + + + + + +
+
+ 高级动作 + + + + + + + +
+
+
已学习红外信号槽位与名称保存在本机数据库,并按槽位号排列
+
等待操作
+
+
`; 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) => `
红外槽位IR ${String(command.slot).padStart(3, "0")}
`).join("") + : "

学习成功后将在这里显示编号与指令名称。

"; + } + + 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(); - void syncPeripheralCatalog(linkedDeviceForCard(nfcSnapshot.card?.uid)); + $("#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 @@
${gatewayLabel}${escapeHtml(gatewayNote)}
${String(health.transport || "GSCU").toUpperCase()} +
${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" } }); } diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index bc53304..10610e8 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -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", diff --git a/config/environments/linux_vehicle_state.example.env b/config/environments/linux_vehicle_state.example.env index c9f7470..1dec520 100644 --- a/config/environments/linux_vehicle_state.example.env +++ b/config/environments/linux_vehicle_state.example.env @@ -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 diff --git a/src/cognitive_agent/application/self_extension.py b/src/cognitive_agent/application/self_extension.py index 609cc4a..48d84b7 100644 --- a/src/cognitive_agent/application/self_extension.py +++ b/src/cognitive_agent/application/self_extension.py @@ -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": - command["data"] = {"no": slot} - else: - command["no"] = slot + if action == "read": + command["messageId"] = request_id + command["data"] = {"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: - self.gateway.start() + with self._gateway_lock: + self._started = True + self.gateway.start() def close(self) -> None: - self.gateway.close() + 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]] = [] - for index in range(repeat): - current = command if index == 0 else {**command, "messageId": _message_id()} - response = self.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 { + with self._gateway_lock: + gateway = self.gateway + for index in range(repeat): + 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) + 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, diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index c28d14c..0ef773e 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -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\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\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: diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 7007850..43df6f6 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -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('', 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 diff --git a/tests/unit/test_nfc_frontend_contract.py b/tests/unit/test_nfc_frontend_contract.py index cfdfb83..1b63698 100644 --- a/tests/unit/test_nfc_frontend_contract.py +++ b/tests/unit/test_nfc_frontend_contract.py @@ -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: diff --git a/tests/unit/test_self_extension.py b/tests/unit/test_self_extension.py index 605f4b0..35347f1 100644 --- a/tests/unit/test_self_extension.py +++ b/tests/unit/test_self_extension.py @@ -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})