添加红外扩展
This commit is contained in:
@@ -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 玩具识别与红外学习
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
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 @@
|
||||
<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" } });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user