添加红外扩展
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",
|
||||
|
||||
Reference in New Issue
Block a user