feat: integrate NFC infrared peripherals and orchestration
This commit is contained in:
@@ -43,8 +43,36 @@ make dev-lan
|
||||
|
||||
自扩展接口统一位于 `/api/extension/*`,GSCU1B 默认以 TCP 模式监听
|
||||
`0.0.0.0:4444`。可通过 `GSCU_TRANSPORT=simulation` 使用演示学习器,或通过
|
||||
`GSCU_TRANSPORT=mqtt` 与 `GSCU_MQTT_*` 配置接入 MQTT。MQTT 依赖可通过
|
||||
项目的 `ir` 可选依赖安装。自扩展不再需要独立的 `8010` 前端服务。
|
||||
`GSCU_TRANSPORT=mqtt` 与 `GSCU_MQTT_*` 配置接入 MQTT。MQTT 依赖已包含在
|
||||
核心环境中;NFC 串口依赖通过 `ir` 可选依赖安装。自扩展不再需要独立的
|
||||
`8010` 前端服务。
|
||||
|
||||
## NFC 玩具识别与红外学习
|
||||
|
||||
统一页面的“外设与扩展能力”区域提供“感应玩具 NFC”入口。点击后将玩具上的
|
||||
NFC 卡片靠近 PCR532/PN532(CH340/CH341 串口)读卡器:新卡可建立或绑定
|
||||
玩具档案,并立即进入现有 GSCU1B 红外学习流程;已绑定卡片会自动识别玩具,
|
||||
展示全部已学习/待学习动作,并可直接发射已验证动作。
|
||||
|
||||
安装串口支持并启动:
|
||||
|
||||
```bash
|
||||
python -m pip install -e ".[ir]"
|
||||
make dev
|
||||
```
|
||||
|
||||
服务会优先复用本机 `ir-toy-learning` 的 MQTT NFC 桥(默认
|
||||
`127.0.0.1:1883`),避免两个进程同时抢占同一个 `COM` 口;桥不可用时再自动选择
|
||||
唯一的 CH340/CH341、PN532 或 PCR532 串口。可通过 `NFC_PORT=COM5` 指定串口,
|
||||
或用 `NFC_MQTT_HOST`、`NFC_MQTT_PORT` 调整桥地址;`NFC_MQTT_DISABLED=true`
|
||||
强制只使用串口,`NFC_DISABLED=true` 可完全禁用读卡线程。
|
||||
|
||||
首次加载时页面会从 `http://localhost:3001/n7web-profile-bridge.html` 安全迁移旧
|
||||
`ir-toy-learning` 浏览器档案,保留工牌 UID、玩具编号、动作名称和 GSCU1B 槽位。
|
||||
不同浏览器运行时,旧工作台会把不含红外原始波形的档案元数据同步到本机 MQTT
|
||||
保留主题 `toy/ir/profiles`,当前服务同样会自动导入。迁移按档案与槽位幂等合并,
|
||||
不会覆盖当前工程中已经建立的能力。NFC UID 与玩具档案的绑定保存在当前浏览器,
|
||||
红外波形仍保存在 GSCU1B 的 1–248 号物理槽位。
|
||||
|
||||
TCP 模式下本服务是服务器,GSCU1B 是客户端:设备配网页中的“TCP服务器”
|
||||
必须填写运行本服务的主机地址,“TCP端口”必须与 `GSCU_TCP_PORT` 一致。
|
||||
|
||||
+24
-5
@@ -7013,6 +7013,8 @@ function renderExecution(result) {
|
||||
}
|
||||
const reportSucceeded = report.spec.status === "succeeded";
|
||||
const partiallySucceeded = result.status === "partially_succeeded";
|
||||
const peripheralRoute = String(result.orchestration_route || "")
|
||||
.startsWith("nfc_peripheral");
|
||||
summary.className = `execution-summary ${reportSucceeded ? "succeeded" : partiallySucceeded ? "partial" : "failed"}`;
|
||||
const presetSave = Boolean(result.preset_config);
|
||||
const modeSave = Boolean(result.mode_profile);
|
||||
@@ -7056,6 +7058,9 @@ function renderExecution(result) {
|
||||
if (result.deferred_requests?.length) {
|
||||
executionDetail += ";未接入域请求已保留为 deferred request";
|
||||
}
|
||||
if (peripheralRoute && operationResults[0]?.message) {
|
||||
executionDetail = result.sequence_summary || operationResults[0].message;
|
||||
}
|
||||
if (!reportSucceeded && !partiallySucceeded) {
|
||||
const failure = report.spec.results.find((item) => item.status === "failed");
|
||||
const failureTelemetry = failure?.telemetry || {};
|
||||
@@ -7078,7 +7083,8 @@ function renderExecution(result) {
|
||||
: failure?.message || "真实台架未返回满足目标的物理反馈。";
|
||||
}
|
||||
const executionTitle = reportSucceeded
|
||||
? modeSave ? "长期模式已保存"
|
||||
? peripheralRoute ? (mqttPublishedUnconfirmed ? "外设指令已发布" : "外设动作已执行")
|
||||
: modeSave ? "长期模式已保存"
|
||||
: presetSave ? "姿态预设已保存"
|
||||
: simulatedHil ? "模拟台架执行成功"
|
||||
: physical
|
||||
@@ -7091,8 +7097,12 @@ function renderExecution(result) {
|
||||
: partiallySucceeded ? "circle-alert" : "circle-x";
|
||||
summary.innerHTML = `<i data-lucide="${executionIcon}"></i><div><strong>${executionTitle}</strong><span>${executionDetail}</span></div>`;
|
||||
$("#resultNavMeta").textContent = reportSucceeded
|
||||
? mqttPublishedUnconfirmed
|
||||
? `${mqttSubmittedOperations.length} 项已提交 · MQTT`
|
||||
? peripheralRoute
|
||||
? result.sequence
|
||||
? `${result.peripheral?.name || "第三方外设"} · ${result.sequence.action_count} 项动作 / ${result.sequence.total_delay_ms / 1000} 秒`
|
||||
: `${result.peripheral?.name || "第三方外设"} · IR SLOT ${result.peripheral?.slot ?? "--"}`
|
||||
: mqttPublishedUnconfirmed
|
||||
? `${mqttSubmittedOperations.length} 项已提交 · MQTT`
|
||||
: `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}`
|
||||
: partiallySucceeded ? `${succeededOperations.length} 项成功 / ${failedOperations.length} 项失败 · 查看明细`
|
||||
: "执行失败 · 查看回读";
|
||||
@@ -7281,8 +7291,13 @@ function renderResult(result) {
|
||||
climate: "空调",
|
||||
roof: "天幕",
|
||||
ambient_light: "氛围灯",
|
||||
self_extension: "第三方外设",
|
||||
};
|
||||
$("#resultTitle").textContent = partiallySucceeded
|
||||
const peripheralRoute = String(result.orchestration_route || "")
|
||||
.startsWith("nfc_peripheral");
|
||||
$("#resultTitle").textContent = peripheralRoute && executionSucceeded
|
||||
? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}`
|
||||
: partiallySucceeded
|
||||
? `${domains.length} 域场景部分完成`
|
||||
: executionSucceeded
|
||||
? result.mode_profile
|
||||
@@ -7563,7 +7578,9 @@ async function submitRequest(event) {
|
||||
}
|
||||
renderResult(payload);
|
||||
completedResult = payload;
|
||||
if (state.executionMode === "hardware") {
|
||||
const peripheralRoute = String(payload.orchestration_route || "")
|
||||
.startsWith("nfc_peripheral");
|
||||
if (state.executionMode === "hardware" && !peripheralRoute) {
|
||||
await synchronizePhysicalSeatState();
|
||||
}
|
||||
const completed = ["succeeded", "partially_succeeded"].includes(payload.status);
|
||||
@@ -7575,6 +7592,8 @@ async function submitRequest(event) {
|
||||
showToast(supplementWarning || (completed
|
||||
? (payload.mode_profile
|
||||
? `${payload.mode_profile.name}已创建并添加到功能模式`
|
||||
: peripheralRoute
|
||||
? `${payload.peripheral?.name || "第三方外设"} · ${payload.peripheral?.action || "动作"}指令已发送`
|
||||
: payload.status === "partially_succeeded"
|
||||
? "已执行可用设备;部分设备未完成,详见执行结果"
|
||||
: simulated
|
||||
|
||||
@@ -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=sync-layout-26" />
|
||||
<link rel="stylesheet" href="/self-extension.css?v=nfc-toy-33" />
|
||||
<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=sync-layout-43" defer></script>
|
||||
<script src="/self-extension.js?v=sync-layout-26" defer></script>
|
||||
<script src="/self-extension.js?v=nfc-toy-33" defer></script>
|
||||
</head>
|
||||
<body class="console-v2">
|
||||
<div class="v2-boot-screen" id="v2BootScreen" role="status" aria-live="polite">
|
||||
|
||||
@@ -2413,3 +2413,686 @@ form > .v2-extension-primary {
|
||||
color: #69bfb6;
|
||||
font: 800 7px "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
/* NFC toy discovery and IR-learning entry ------------------------------- */
|
||||
|
||||
.v2-extension-sync-mount {
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.v2-extension-sync-heading > div:first-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
column-gap: 8px;
|
||||
}
|
||||
|
||||
.v2-extension-sync-heading > div:first-child > small {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.v2-extension-sync-heading > div:first-child > h3 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.v2-extension-nfc-launch {
|
||||
min-height: 25px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 7px;
|
||||
color: #dffffa;
|
||||
background: rgba(74, 194, 182, 0.14);
|
||||
border: 1px solid rgba(111, 221, 210, 0.22);
|
||||
border-radius: 7px;
|
||||
font-size: 7.5px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: 150ms ease;
|
||||
}
|
||||
|
||||
.v2-extension-nfc-launch:hover {
|
||||
color: #fff;
|
||||
background: rgba(74, 194, 182, 0.24);
|
||||
border-color: rgba(111, 221, 210, 0.4);
|
||||
}
|
||||
|
||||
.v2-extension-nfc-launch svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.v2-nfc-inline {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 35px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px;
|
||||
color: #d9efec;
|
||||
background: linear-gradient(135deg, rgba(88, 197, 186, 0.11), rgba(255, 255, 255, 0.035));
|
||||
border: 1px solid rgba(108, 209, 199, 0.14);
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.v2-nfc-inline[data-state="recognized"] {
|
||||
background: linear-gradient(135deg, rgba(36, 184, 137, 0.16), rgba(255, 255, 255, 0.04));
|
||||
border-color: rgba(65, 211, 161, 0.28);
|
||||
}
|
||||
|
||||
.v2-nfc-inline[data-state="new-card"] {
|
||||
background: linear-gradient(135deg, rgba(226, 172, 74, 0.15), rgba(255, 255, 255, 0.04));
|
||||
border-color: rgba(236, 184, 92, 0.27);
|
||||
}
|
||||
|
||||
.v2-nfc-inline-icon {
|
||||
position: relative;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #71d4ca;
|
||||
background: rgba(75, 190, 179, 0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.v2-nfc-inline[data-state="recognized"] .v2-nfc-inline-icon {
|
||||
color: #70e1b6;
|
||||
background: rgba(55, 200, 149, 0.13);
|
||||
}
|
||||
|
||||
.v2-nfc-inline[data-state="new-card"] .v2-nfc-inline-icon {
|
||||
color: #efc070;
|
||||
background: rgba(226, 172, 74, 0.12);
|
||||
}
|
||||
|
||||
.v2-nfc-inline-icon svg {
|
||||
width: 17px;
|
||||
}
|
||||
|
||||
.v2-nfc-inline[data-state="waiting"] .v2-nfc-inline-icon b {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border: 1px solid rgba(112, 215, 204, 0.35);
|
||||
border-radius: 9px;
|
||||
animation: extension-nfc-pulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
.v2-nfc-inline > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.v2-nfc-inline small {
|
||||
color: #68bcb4;
|
||||
font: 800 7px/1.2 "SFMono-Regular", Consolas, monospace;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.v2-nfc-inline strong,
|
||||
.v2-nfc-inline p {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-nfc-inline strong {
|
||||
color: #effbf9;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.v2-nfc-inline p {
|
||||
color: #8eaaa7;
|
||||
font-size: 7.5px;
|
||||
}
|
||||
|
||||
.v2-nfc-inline > div > div {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.v2-nfc-inline > div > div i {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 5px;
|
||||
color: #b9ddd8;
|
||||
background: rgba(96, 196, 185, 0.09);
|
||||
border-radius: 999px;
|
||||
font-size: 6.5px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.v2-nfc-inline > button {
|
||||
min-height: 29px;
|
||||
padding: 0 8px;
|
||||
color: #123c3a;
|
||||
background: #83d8cf;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
font-size: 7.5px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.v2-nfc-dialog {
|
||||
width: min(900px, calc(100vw - 28px));
|
||||
max-width: 900px;
|
||||
max-height: min(860px, calc(100dvh - 28px));
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
color: #203d3d;
|
||||
background: #f4f8f7;
|
||||
border: 1px solid #dce8e6;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 30px 80px rgba(17, 52, 52, 0.28);
|
||||
}
|
||||
|
||||
.v2-nfc-dialog > header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
padding: 22px 24px 17px;
|
||||
background: rgba(248, 251, 250, 0.94);
|
||||
border-bottom: 1px solid #e0e9e7;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.v2-nfc-dialog > header h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.v2-nfc-stage {
|
||||
margin: 20px 24px 16px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe9e7;
|
||||
border-radius: 19px;
|
||||
box-shadow: 0 12px 32px rgba(30, 73, 71, 0.07);
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header {
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 15px 17px;
|
||||
background: linear-gradient(135deg, #153b3c, #1c514e);
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header > span {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #99e6de;
|
||||
background: rgba(114, 219, 208, 0.12);
|
||||
border: 1px solid rgba(139, 230, 221, 0.16);
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header svg {
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header small {
|
||||
color: #76c5bd;
|
||||
font: 850 9px/1.2 "SFMono-Regular", Consolas, monospace;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header h3 {
|
||||
margin: 3px 0;
|
||||
color: #f5fffd;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header p {
|
||||
margin: 0;
|
||||
color: #a5c5c1;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header > b {
|
||||
padding: 6px 8px;
|
||||
color: #9cb3b0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 7px;
|
||||
font: 850 8px "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header > b[data-state="online"] {
|
||||
color: #8de8c1;
|
||||
background: rgba(52, 202, 147, 0.11);
|
||||
}
|
||||
|
||||
.v2-nfc-body {
|
||||
min-height: 260px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.v2-nfc-waiting {
|
||||
min-height: 224px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.v2-nfc-reader-visual {
|
||||
position: relative;
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #177d76;
|
||||
background: radial-gradient(circle, #ecf9f7 0 31%, #dff3ef 32% 43%, #f5fbfa 44%);
|
||||
border: 1px solid #d6eeea;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.v2-nfc-reader-visual svg {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.v2-nfc-reader-visual b {
|
||||
position: absolute;
|
||||
inset: 18px;
|
||||
border: 1px solid rgba(38, 156, 144, 0.28);
|
||||
border-radius: 22px;
|
||||
animation: extension-nfc-pulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
.v2-nfc-reader-visual b:last-child {
|
||||
animation-delay: 900ms;
|
||||
}
|
||||
|
||||
.v2-nfc-waiting.offline .v2-nfc-reader-visual,
|
||||
.v2-nfc-waiting.offline .v2-nfc-reader-visual b {
|
||||
color: #879a98;
|
||||
border-color: #dce5e3;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.v2-nfc-waiting > div {
|
||||
max-width: 390px;
|
||||
}
|
||||
|
||||
.v2-nfc-waiting small,
|
||||
.v2-nfc-detected-heading small,
|
||||
.v2-nfc-recognized-heading small,
|
||||
.v2-nfc-capability-heading small {
|
||||
color: #6f9793;
|
||||
font: 850 9px/1.3 "SFMono-Regular", Consolas, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.v2-nfc-waiting strong {
|
||||
display: block;
|
||||
margin: 7px 0 6px;
|
||||
color: #183f3e;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.v2-nfc-waiting p {
|
||||
margin: 0;
|
||||
color: #6c8381;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.v2-nfc-detected-heading,
|
||||
.v2-nfc-recognized-heading {
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #f6faf9;
|
||||
border: 1px solid #e2ecea;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.v2-nfc-detected-heading > span,
|
||||
.v2-nfc-recognized-heading > span {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #147a73;
|
||||
background: #e3f4f1;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.v2-nfc-detected-heading svg,
|
||||
.v2-nfc-recognized-heading svg {
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
.v2-nfc-recognized-heading > i,
|
||||
.v2-nfc-recognized-heading > i svg {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
color: #27a878;
|
||||
}
|
||||
|
||||
.v2-nfc-detected-heading strong,
|
||||
.v2-nfc-recognized-heading strong {
|
||||
display: block;
|
||||
margin: 3px 0;
|
||||
color: #183f3e;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.v2-nfc-detected-heading p,
|
||||
.v2-nfc-recognized-heading p {
|
||||
margin: 0;
|
||||
color: #718784;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.v2-nfc-create-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.v2-nfc-create-grid label,
|
||||
.v2-nfc-existing {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.v2-nfc-create-grid label > span,
|
||||
.v2-nfc-existing > span {
|
||||
color: #55706e;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.v2-nfc-create-grid input,
|
||||
.v2-nfc-create-grid select,
|
||||
.v2-nfc-existing select {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
color: #183d3b;
|
||||
background: #fff;
|
||||
border: 1px solid #d5e3e0;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.v2-nfc-create-grid input:focus,
|
||||
.v2-nfc-create-grid select:focus,
|
||||
.v2-nfc-existing select:focus {
|
||||
border-color: #42a69d;
|
||||
box-shadow: 0 0 0 3px rgba(66, 166, 157, 0.1);
|
||||
}
|
||||
|
||||
.v2-nfc-primary-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.v2-nfc-primary-actions button,
|
||||
.v2-nfc-recognized-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.v2-nfc-primary-actions svg,
|
||||
.v2-nfc-recognized-actions svg {
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
.v2-nfc-existing {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e3ecea;
|
||||
}
|
||||
|
||||
.v2-nfc-existing > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.v2-nfc-existing button,
|
||||
.v2-nfc-recognized-actions > button:last-child {
|
||||
padding: 0 16px;
|
||||
color: #3a6865;
|
||||
background: #eef6f4;
|
||||
border: 1px solid #d4e5e2;
|
||||
border-radius: 10px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 18px 2px 10px;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-heading strong {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #193f3d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-heading > span {
|
||||
color: #4d817c;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: #f7faf9;
|
||||
border: 1px solid #e1eae8;
|
||||
border-radius: 11px;
|
||||
transition: 150ms ease;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #91c8c1;
|
||||
box-shadow: 0 6px 16px rgba(34, 93, 88, 0.08);
|
||||
}
|
||||
|
||||
.v2-nfc-capability-card.learned {
|
||||
background: #f0faf6;
|
||||
border-color: #cfe9de;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-primary {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 62px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 18px;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 3px 8px;
|
||||
padding: 10px 11px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-primary span {
|
||||
overflow: hidden;
|
||||
color: #244744;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-primary small {
|
||||
overflow: hidden;
|
||||
color: #78908d;
|
||||
font-size: 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-primary svg {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / 3;
|
||||
align-self: center;
|
||||
width: 15px;
|
||||
color: #3c988e;
|
||||
}
|
||||
|
||||
.v2-nfc-capability-card.pending .v2-nfc-capability-primary svg {
|
||||
color: #9baaa8;
|
||||
}
|
||||
|
||||
.v2-nfc-relearn {
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 5px 9px;
|
||||
color: #397b75;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
border: 0;
|
||||
border-top: 1px solid #d9ebe4;
|
||||
cursor: pointer;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.v2-nfc-relearn:hover {
|
||||
color: #175f59;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.v2-nfc-relearn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.v2-nfc-recognized-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.v2-nfc-recognized-actions button {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.v2-nfc-recognized-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.v2-nfc-footnote {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 24px 22px;
|
||||
color: #738a87;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.v2-nfc-footnote svg {
|
||||
width: 15px;
|
||||
color: #398e86;
|
||||
}
|
||||
|
||||
@keyframes extension-nfc-pulse {
|
||||
from { transform: scale(0.72); opacity: 0.8; }
|
||||
to { transform: scale(1.48); opacity: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.v2-nfc-dialog {
|
||||
width: calc(100vw - 14px);
|
||||
max-height: calc(100dvh - 14px);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.v2-nfc-dialog > header,
|
||||
.v2-nfc-stage > header,
|
||||
.v2-nfc-body {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.v2-nfc-stage {
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header {
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header > span {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.v2-nfc-stage > header > b {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.v2-nfc-waiting {
|
||||
min-height: 250px;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.v2-nfc-create-grid,
|
||||
.v2-nfc-capability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.v2-nfc-recognized-heading,
|
||||
.v2-nfc-detected-heading {
|
||||
grid-template-columns: 46px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.v2-nfc-recognized-heading > span,
|
||||
.v2-nfc-detected-heading > span {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.v2-nfc-recognized-heading > i {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
const MAX_TOTAL_PLAN_DELAY_MS = 60000;
|
||||
const HEALTH_POLL_INTERVAL_MS = 3000;
|
||||
const HEALTH_REQUEST_TIMEOUT_MS = 5000;
|
||||
const NFC_POLL_INTERVAL_MS = 700;
|
||||
const LEGACY_PROFILE_KEY = "ir-lab-profiles-v2";
|
||||
const LEGACY_PROFILE_BRIDGE_URLS = [
|
||||
"http://localhost:3001/n7web-profile-bridge.html",
|
||||
"http://127.0.0.1:3001/n7web-profile-bridge.html",
|
||||
];
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
@@ -360,6 +366,17 @@
|
||||
let healthRefreshPromise = null;
|
||||
let healthPollTimer = null;
|
||||
let healthToastRequested = false;
|
||||
let nfcSnapshot = {
|
||||
revision: -1,
|
||||
reader: { online: false, state: "starting", port: "auto", error: null },
|
||||
card: null,
|
||||
legacyProfiles: [],
|
||||
};
|
||||
let nfcPollTimer = null;
|
||||
let legacySameOriginChecked = false;
|
||||
let legacyMigrationCompleted = false;
|
||||
const legacyBridgeAttempts = new Set();
|
||||
let renderUnifiedExtensionView = null;
|
||||
let latestVehicleStateTurn = -1;
|
||||
let sharedTwinBand = null;
|
||||
let sharedTwinHome = null;
|
||||
@@ -393,6 +410,31 @@
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function infraredSignalFromResponse(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (["ir_data", "irData", "infraredData"].includes(key)) {
|
||||
if (typeof child === "string" && child.trim().length > 4) return child.trim();
|
||||
if (Array.isArray(child) && child.length) return JSON.stringify(child);
|
||||
}
|
||||
const nested = infraredSignalFromResponse(child);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function infraredFingerprint(signal) {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(signal),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.slice(0, 6)
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function validSlot(value) {
|
||||
const slot = Number(value);
|
||||
return Number.isInteger(slot) && slot >= SLOT_MIN && slot <= SLOT_MAX;
|
||||
@@ -476,6 +518,7 @@
|
||||
countdownTimer: null,
|
||||
abortController: null,
|
||||
tested: false,
|
||||
fingerprint: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -857,13 +900,19 @@
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
renderMetrics();
|
||||
renderHardwareGrid();
|
||||
renderControl();
|
||||
renderTypeChooser();
|
||||
if ($("#extensionHardwareCount")) renderMetrics();
|
||||
if ($("#extensionHardwareGrid")) renderHardwareGrid();
|
||||
if ($("#extensionLearnedGrid")) renderControl();
|
||||
if ($("#extensionTypeChooser")) renderTypeChooser();
|
||||
if ($("#extensionNfcDialog")) renderNfcDialog();
|
||||
renderUnifiedExtensionView?.();
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
}
|
||||
|
||||
function normalizeCardUid(value) {
|
||||
return String(value || "").replace(/[^0-9a-f]/gi, "").toUpperCase();
|
||||
}
|
||||
|
||||
function renderMetrics() {
|
||||
$("#extensionHardwareCount").textContent = String(state.devices.length);
|
||||
$("#extensionCapabilityCount").textContent = String(learnedCapabilities().length);
|
||||
@@ -1070,16 +1119,33 @@
|
||||
const device = state.devices.find((item) => item.id === capability?.deviceId);
|
||||
if (!capability || !device) return;
|
||||
const type = TOY_TYPES[device.type] || TOY_TYPES.other;
|
||||
const relearning = capability.status === "learned"
|
||||
&& capability.verified
|
||||
&& validSlot(capability.slot);
|
||||
const targetSlot = relearning ? Number(capability.slot) : allocateSlot();
|
||||
resetLearnStatus();
|
||||
learning.draft = { capabilityId: capability.id, deviceId: device.id, slot: allocateSlot() };
|
||||
$("#extensionLearnTitle").textContent = `学习“${capability.name}”`;
|
||||
$("#extensionLearnPath").textContent = `${type.label} · ${device.name}`;
|
||||
learning.draft = {
|
||||
capabilityId: capability.id,
|
||||
deviceId: device.id,
|
||||
slot: targetSlot,
|
||||
relearning,
|
||||
};
|
||||
$("#extensionLearnTitle").textContent = `${relearning ? "重新学习" : "学习"}“${capability.name}”`;
|
||||
$("#extensionLearnPath").textContent = relearning
|
||||
? `${type.label} · ${device.name} · 将覆盖 IR ${String(targetSlot).padStart(3, "0")} 原信号`
|
||||
: `${type.label} · ${device.name}`;
|
||||
$("#extensionLearnIcon").innerHTML = `<i data-lucide="${type.icon}"></i>`;
|
||||
$("#extensionLearnDevice").textContent = device.name;
|
||||
$("#extensionLearnAction").textContent = capability.name;
|
||||
$("#extensionLearnAction").textContent = `${capability.name}${relearning ? "(重新学习)" : ""}`;
|
||||
$("#extensionLearnAliases").value = (capability.aliases || []).join("、");
|
||||
$("#extensionLearnRisk").value = capability.risk || "low";
|
||||
$("#extensionLearnSlot").value = allocateSlot() || "";
|
||||
$("#extensionLearnSlot").value = targetSlot || "";
|
||||
$("#extensionContinueLearn").textContent = relearning
|
||||
? "继续并覆盖原红外信号"
|
||||
: "继续接收遥控信号";
|
||||
$("#extensionConfirmLearn").textContent = relearning
|
||||
? "确认并完成重新学习"
|
||||
: "确认并完成学习";
|
||||
showLearnPage(1);
|
||||
openDialog("extensionLearnDialog");
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
@@ -1151,11 +1217,33 @@
|
||||
if (countdown <= 0) window.clearInterval(learning.countdownTimer);
|
||||
}, 1000);
|
||||
try {
|
||||
await sendGatewayCommand(
|
||||
const learnResult = await sendGatewayCommand(
|
||||
"learn",
|
||||
{ slot: learning.draft.slot },
|
||||
learning.abortController.signal,
|
||||
);
|
||||
let learnedSignal = null;
|
||||
let readError = null;
|
||||
await delay(700);
|
||||
for (let attempt = 0; attempt < 3 && !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);
|
||||
}
|
||||
learnedSignal ||= infraredSignalFromResponse(learnResult);
|
||||
if (!learnedSignal) {
|
||||
throw new Error(
|
||||
readError?.message
|
||||
? `学习后无法回读槽位:${readError.message}`
|
||||
: "学习回执成功,但物理槽位回读为空;请对准学习器并短按遥控器按键。",
|
||||
);
|
||||
}
|
||||
learning.fingerprint = await infraredFingerprint(learnedSignal);
|
||||
learning.signalBytes = learnedSignal.length;
|
||||
window.clearInterval(learning.countdownTimer);
|
||||
learning.phase = "learned";
|
||||
$("#extensionLearnAnimation").dataset.state = "success";
|
||||
@@ -1213,6 +1301,8 @@
|
||||
(item) => item.id === learning.draft.capabilityId,
|
||||
);
|
||||
if (!capability) return;
|
||||
const relearning = Boolean(learning.draft.relearning);
|
||||
const learnedAt = new Date().toISOString();
|
||||
Object.assign(capability, {
|
||||
aliases: [...new Set([capability.name, ...learning.draft.aliases])],
|
||||
risk: learning.draft.risk,
|
||||
@@ -1220,13 +1310,22 @@
|
||||
status: "learned",
|
||||
verified: true,
|
||||
enabled: true,
|
||||
learnedAt: new Date().toISOString(),
|
||||
fingerprint: learning.fingerprint || capability.fingerprint || null,
|
||||
signalBytes: learning.signalBytes || capability.signalBytes || null,
|
||||
learnedAt,
|
||||
relearnedAt: relearning ? learnedAt : capability.relearnedAt || null,
|
||||
signalSource: "n7web",
|
||||
});
|
||||
state.sessionStartedAt ||= new Date().toISOString();
|
||||
saveState();
|
||||
closeDialog("extensionLearnDialog");
|
||||
renderAll();
|
||||
showToast(`“${capability.name}”已学习完成。`, "success");
|
||||
showToast(
|
||||
relearning
|
||||
? `“${capability.name}”已重新学习并覆盖 IR ${String(capability.slot).padStart(3, "0")}。`
|
||||
: `“${capability.name}”已学习完成。`,
|
||||
"success",
|
||||
);
|
||||
learning = freshLearningSession();
|
||||
}
|
||||
|
||||
@@ -1255,10 +1354,13 @@
|
||||
device_id: device?.id,
|
||||
device_name: device?.name,
|
||||
device_type: device?.type,
|
||||
card_uid: device?.cardUid || null,
|
||||
toy_number: device?.toyNumber || null,
|
||||
type_label: type.label,
|
||||
action: capability.name,
|
||||
aliases: capability.aliases || [],
|
||||
risk: capability.risk || "low",
|
||||
slot: capability.slot,
|
||||
};
|
||||
}).filter((item) => item.device_id);
|
||||
}
|
||||
@@ -2291,6 +2393,559 @@
|
||||
});
|
||||
}
|
||||
|
||||
function linkedDeviceForCard(uidValue) {
|
||||
const uidValueNormalized = normalizeCardUid(uidValue);
|
||||
if (!uidValueNormalized) return null;
|
||||
return state.devices.find(
|
||||
(device) => normalizeCardUid(device.cardUid) === uidValueNormalized,
|
||||
) || null;
|
||||
}
|
||||
|
||||
function inferLegacyToyType(profile) {
|
||||
const text = [
|
||||
profile?.name,
|
||||
profile?.model,
|
||||
...(Array.isArray(profile?.actions)
|
||||
? profile.actions.flatMap((action) => [action?.name, action?.code])
|
||||
: []),
|
||||
].join(" ").toLowerCase();
|
||||
if (/(氛围|灯|亮度|颜色|rgb|light|colour|color)/i.test(text)) return "light";
|
||||
if (/(声音|音乐|音量|播放|sound|music|volume)/i.test(text)) return "sound";
|
||||
if (/(玩偶|娃娃|唱歌|说话|doll)/i.test(text)) return "doll";
|
||||
if (/(机械|升降|伸缩|正转|反转|mechanical)/i.test(text)) return "mechanical";
|
||||
if (/(前进|后退|左转|右转|移动|小车|action|motion)/i.test(text)) return "action";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function legacyIdentifier(prefix, value) {
|
||||
const suffix = String(value || "profile")
|
||||
.replace(/[^a-z0-9_-]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64) || "profile";
|
||||
let candidate = `${prefix}-${suffix}`;
|
||||
let index = 2;
|
||||
while (state.devices.some((device) => device.id === candidate)) {
|
||||
candidate = `${prefix}-${suffix}-${index}`;
|
||||
index += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function legacyCapabilityRisk(action) {
|
||||
const text = `${action?.name || ""} ${action?.code || ""}`;
|
||||
return /(前进|后退|左转|右转|移动|旋转|升降|伸缩)/.test(text) ? "high" : "low";
|
||||
}
|
||||
|
||||
function importLegacyProfiles(value) {
|
||||
if (!Array.isArray(value)) return { devices: 0, actions: 0 };
|
||||
let importedDevices = 0;
|
||||
let importedActions = 0;
|
||||
let changed = false;
|
||||
|
||||
value.forEach((profile, profileIndex) => {
|
||||
if (!profile || typeof profile !== "object") return;
|
||||
const legacyActions = Array.isArray(profile.actions)
|
||||
? profile.actions.filter((action) => action?.learned && validSlot(action.slot))
|
||||
: [];
|
||||
const cardUid = normalizeCardUid(profile.cardUid);
|
||||
if (!cardUid && !legacyActions.length) return;
|
||||
const legacyProfileId = String(profile.id || `profile-${profileIndex}`);
|
||||
const type = inferLegacyToyType(profile);
|
||||
let device = cardUid ? linkedDeviceForCard(cardUid) : null;
|
||||
device ||= state.devices.find((item) => item.legacyProfileId === legacyProfileId) || null;
|
||||
|
||||
if (!device) {
|
||||
device = {
|
||||
id: legacyIdentifier("legacy-toy", legacyProfileId),
|
||||
name: String(profile.name || `玩具 ${profile.toyNumber || nextToyNumber()}`),
|
||||
type,
|
||||
aliases: [String(profile.name || "玩具")],
|
||||
toyNumber: String(profile.toyNumber || nextToyNumber()),
|
||||
cardUid: cardUid || null,
|
||||
model: String(profile.model || "ir-toy-learning 导入外设"),
|
||||
legacyProfileId,
|
||||
importedFrom: "ir-toy-learning",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
state.devices.push(device);
|
||||
importedDevices += 1;
|
||||
changed = true;
|
||||
} else {
|
||||
const before = JSON.stringify([
|
||||
device.name,
|
||||
device.type,
|
||||
device.toyNumber,
|
||||
device.cardUid,
|
||||
device.legacyProfileId,
|
||||
]);
|
||||
if (!device.cardUid && cardUid) device.cardUid = cardUid;
|
||||
if (!device.toyNumber && profile.toyNumber) device.toyNumber = String(profile.toyNumber);
|
||||
if (!device.legacyProfileId) device.legacyProfileId = legacyProfileId;
|
||||
if (device.type === "other" && type !== "other") device.type = type;
|
||||
if (/^玩具\s*\d+$/i.test(device.name) && profile.name) device.name = String(profile.name);
|
||||
device.importedFrom ||= "ir-toy-learning";
|
||||
changed ||= before !== JSON.stringify([
|
||||
device.name,
|
||||
device.type,
|
||||
device.toyNumber,
|
||||
device.cardUid,
|
||||
device.legacyProfileId,
|
||||
]);
|
||||
}
|
||||
|
||||
state.selectedDeviceByType[device.type] = device.id;
|
||||
legacyActions.forEach((action, actionIndex) => {
|
||||
const slot = Number(action.slot);
|
||||
const name = String(action.name || action.code || `红外动作 ${slot}`);
|
||||
const legacyActionId = String(action.id || `${legacyProfileId}:${actionIndex}:${slot}`);
|
||||
const aliases = [...new Set([
|
||||
name,
|
||||
typeof action.code === "string" ? action.code : "",
|
||||
].filter(Boolean))];
|
||||
let capability = state.capabilities.find((item) => (
|
||||
item.deviceId === device.id
|
||||
&& (item.legacyActionId === legacyActionId
|
||||
|| item.name === name)
|
||||
));
|
||||
if (!capability) {
|
||||
capability = {
|
||||
id: uid("legacy-cap"),
|
||||
deviceId: device.id,
|
||||
key: `legacy_${slot}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
state.capabilities.push(capability);
|
||||
importedActions += 1;
|
||||
changed = true;
|
||||
}
|
||||
// A signal learned again in N7Web is authoritative. Repeated NFC snapshots
|
||||
// may still contain the old ir-toy-learning metadata and must not roll it back.
|
||||
if (capability.signalSource === "n7web") return;
|
||||
const before = JSON.stringify(capability);
|
||||
Object.assign(capability, {
|
||||
name,
|
||||
aliases,
|
||||
category: type === "light" ? "effect" : "custom",
|
||||
risk: legacyCapabilityRisk(action),
|
||||
slot,
|
||||
status: "learned",
|
||||
verified: true,
|
||||
enabled: true,
|
||||
fingerprint: action.fingerprint || capability.fingerprint || null,
|
||||
learnedAt: action.updatedAt || capability.learnedAt || new Date().toISOString(),
|
||||
legacyActionId,
|
||||
importedFrom: "ir-toy-learning",
|
||||
});
|
||||
changed ||= before !== JSON.stringify(capability);
|
||||
});
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
state.sessionStartedAt ||= new Date().toISOString();
|
||||
saveState();
|
||||
renderAll();
|
||||
void syncPeripheralCatalog();
|
||||
}
|
||||
return { devices: importedDevices, actions: importedActions };
|
||||
}
|
||||
|
||||
function readSameOriginLegacyProfiles() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LEGACY_PROFILE_KEY) || "null");
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requestLegacyProfiles(bridgeUrl) {
|
||||
return new Promise((resolve) => {
|
||||
const iframe = document.createElement("iframe");
|
||||
const bridgeOrigin = new URL(bridgeUrl).origin;
|
||||
const requestId = uid("legacy-import");
|
||||
let settled = false;
|
||||
const cleanup = (profiles = null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("message", receive);
|
||||
iframe.remove();
|
||||
resolve(profiles);
|
||||
};
|
||||
const request = () => iframe.contentWindow?.postMessage({
|
||||
type: "N7WEB_REQUEST_IR_TOY_PROFILES",
|
||||
requestId,
|
||||
}, bridgeOrigin);
|
||||
const receive = (event) => {
|
||||
if (event.origin !== bridgeOrigin || event.source !== iframe.contentWindow) return;
|
||||
if (event.data?.type === "IR_TOY_LEGACY_BRIDGE_READY") {
|
||||
request();
|
||||
return;
|
||||
}
|
||||
if (event.data?.type !== "IR_TOY_LEGACY_PROFILES"
|
||||
|| event.data.requestId !== requestId) return;
|
||||
cleanup(Array.isArray(event.data.profiles) ? event.data.profiles : []);
|
||||
};
|
||||
const timer = window.setTimeout(() => cleanup(), 1800);
|
||||
iframe.src = bridgeUrl;
|
||||
iframe.hidden = true;
|
||||
iframe.setAttribute("aria-hidden", "true");
|
||||
iframe.addEventListener("load", request, { once: true });
|
||||
window.addEventListener("message", receive);
|
||||
document.body.append(iframe);
|
||||
});
|
||||
}
|
||||
|
||||
function hasImportableLegacyProfiles(profiles) {
|
||||
return Array.isArray(profiles) && profiles.some((profile) => (
|
||||
normalizeCardUid(profile?.cardUid)
|
||||
|| (Array.isArray(profile?.actions)
|
||||
&& profile.actions.some((action) => action?.learned && validSlot(action.slot)))
|
||||
));
|
||||
}
|
||||
|
||||
function legacyBridgeUrls(addresses = []) {
|
||||
const currentHostUrl = window.location.hostname
|
||||
? `http://${window.location.hostname}:3001/n7web-profile-bridge.html`
|
||||
: null;
|
||||
const addressUrls = (Array.isArray(addresses) ? addresses : [])
|
||||
.filter((address) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(String(address)))
|
||||
.map((address) => `http://${address}:3001/n7web-profile-bridge.html`);
|
||||
return [...new Set([currentHostUrl, ...LEGACY_PROFILE_BRIDGE_URLS, ...addressUrls].filter(Boolean))];
|
||||
}
|
||||
|
||||
async function migrateLegacyProfiles(addresses = []) {
|
||||
if (legacyMigrationCompleted) return;
|
||||
if (!legacySameOriginChecked) {
|
||||
legacySameOriginChecked = true;
|
||||
const sameOriginProfiles = readSameOriginLegacyProfiles();
|
||||
if (hasImportableLegacyProfiles(sameOriginProfiles)) {
|
||||
const imported = importLegacyProfiles(sameOriginProfiles);
|
||||
legacyMigrationCompleted = true;
|
||||
if (imported.devices || imported.actions) {
|
||||
showToast(`已迁移 ${imported.devices} 个玩具、${imported.actions} 项红外动作。`, "success");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const bridgeUrl of legacyBridgeUrls(addresses)) {
|
||||
if (legacyBridgeAttempts.has(bridgeUrl)) continue;
|
||||
legacyBridgeAttempts.add(bridgeUrl);
|
||||
const profiles = await requestLegacyProfiles(bridgeUrl);
|
||||
if (!hasImportableLegacyProfiles(profiles)) continue;
|
||||
const imported = importLegacyProfiles(profiles);
|
||||
legacyMigrationCompleted = true;
|
||||
if (imported.devices || imported.actions) {
|
||||
showToast(`已从 ir-toy-learning 迁移 ${imported.devices} 个玩具、${imported.actions} 项红外动作。`, "success");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function nextToyNumber() {
|
||||
const highest = state.devices.reduce((current, device) => {
|
||||
const parsed = Number.parseInt(device.toyNumber || "", 10);
|
||||
return Number.isFinite(parsed) ? Math.max(current, parsed) : current;
|
||||
}, 0);
|
||||
return String(highest + 1).padStart(3, "0");
|
||||
}
|
||||
|
||||
function nfcReaderNote(reader = nfcSnapshot.reader) {
|
||||
if (reader?.online) {
|
||||
const firmware = reader.firmware ? ` · PN532 ${reader.firmware}` : "";
|
||||
return `${reader.port || "串口"} 已就绪${firmware}`;
|
||||
}
|
||||
if (reader?.state === "connecting" || reader?.state === "starting") {
|
||||
return "正在查找 PN532 / PCR532 读卡器";
|
||||
}
|
||||
return reader?.error || "请连接 NFC 读卡器并确认 CH340 / CH341 驱动可用";
|
||||
}
|
||||
|
||||
function renderNfcDialog() {
|
||||
const stage = $("#extensionNfcStage");
|
||||
const body = $("#extensionNfcBody");
|
||||
const status = $("#extensionNfcReaderStatus");
|
||||
const badge = $("#extensionNfcReaderBadge");
|
||||
if (!stage || !body || !status || !badge) return;
|
||||
|
||||
const reader = nfcSnapshot.reader || {};
|
||||
const card = nfcSnapshot.card;
|
||||
const linkedDevice = linkedDeviceForCard(card?.uid);
|
||||
status.textContent = nfcReaderNote(reader);
|
||||
badge.textContent = reader.online ? "READER READY" : "READER OFFLINE";
|
||||
badge.dataset.state = reader.online ? "online" : "offline";
|
||||
|
||||
if (!reader.online) {
|
||||
stage.dataset.state = "offline";
|
||||
body.innerHTML = `<div class="v2-nfc-waiting offline"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>NFC READER</small><strong>读卡器尚未就绪</strong><p>${escapeHtml(nfcReaderNote(reader))}</p></div></div>`;
|
||||
} else if (!card?.uid) {
|
||||
stage.dataset.state = "waiting";
|
||||
body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>WAITING FOR TOY</small><strong>请将玩具 NFC 靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后会自动显示该玩具及其动作能力。</p></div></div>`;
|
||||
} else if (!linkedDevice) {
|
||||
const suggestedNumber = nextToyNumber();
|
||||
stage.dataset.state = "new-card";
|
||||
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到新玩具卡片</small><strong>${escapeHtml(card.uid)}</strong><p>这张 NFC 尚未绑定,可新建玩具档案或关联到已有玩具。</p></div></div>
|
||||
<div class="v2-nfc-create-grid">
|
||||
<label><span>玩具名称</span><input id="extensionNfcToyName" maxlength="48" value="玩具 ${suggestedNumber}" /></label>
|
||||
<label><span>玩具类别</span><select id="extensionNfcToyType">${Object.entries(TOY_TYPES).map(([key, type]) => `<option value="${escapeHtml(key)}">${escapeHtml(type.label)}</option>`).join("")}</select></label>
|
||||
</div>
|
||||
<div class="v2-nfc-primary-actions"><button class="v2-extension-primary" type="button" data-nfc-create-and-learn><i data-lucide="radio-tower"></i>建立档案并开始学习</button></div>
|
||||
${state.devices.length ? `<div class="v2-nfc-existing"><span>或绑定到已有玩具</span><div><select id="extensionNfcExistingDevice">${state.devices.map((device) => `<option value="${escapeHtml(device.id)}">${device.toyNumber ? `#${escapeHtml(device.toyNumber)} · ` : ""}${escapeHtml(device.name)}</option>`).join("")}</select><button type="button" data-nfc-bind-existing>绑定</button></div></div>` : ""}`;
|
||||
} else {
|
||||
const type = TOY_TYPES[linkedDevice.type] || TOY_TYPES.other;
|
||||
const capabilities = deviceCapabilities(linkedDevice.id);
|
||||
const learned = learnedCapabilities(linkedDevice.id);
|
||||
const firstPending = capabilities.find((capability) => capability.status !== "learned");
|
||||
stage.dataset.state = "recognized";
|
||||
body.innerHTML = `<div class="v2-nfc-recognized-heading">
|
||||
<span data-extension-type="${escapeHtml(linkedDevice.type || "other")}"><i data-lucide="${type.icon}"></i></span>
|
||||
<div><small>已识别并绑定 · ${escapeHtml(card.uid)}</small><strong>${linkedDevice.toyNumber ? `#${escapeHtml(linkedDevice.toyNumber)} · ` : ""}${escapeHtml(linkedDevice.name)}</strong><p>${escapeHtml(type.label)} · ${learned.length} 项动作可执行</p></div>
|
||||
<i data-lucide="badge-check"></i>
|
||||
</div>
|
||||
<div class="v2-nfc-capability-heading"><div><small>TOY ACTION CAPABILITIES</small><strong>玩具动作能力</strong></div><span>${learned.length}/${capabilities.length} 已学习</span></div>
|
||||
<div class="v2-nfc-capability-grid">${capabilities.map((capability) => {
|
||||
const isLearned = learned.some((item) => item.id === capability.id);
|
||||
if (!isLearned) {
|
||||
return `<div class="v2-nfc-capability-card pending"><button class="v2-nfc-capability-primary" type="button" data-nfc-learn-capability="${escapeHtml(capability.id)}"><span>${escapeHtml(capability.name)}</span><small>待学习 · 点击录入</small><i data-lucide="plus"></i></button></div>`;
|
||||
}
|
||||
return `<div class="v2-nfc-capability-card learned"><button class="v2-nfc-capability-primary" type="button" data-nfc-run-capability="${escapeHtml(capability.id)}"><span>${escapeHtml(capability.name)}</span><small>IR ${String(capability.slot).padStart(3, "0")} · 点击发射</small><i data-lucide="send"></i></button><button class="v2-nfc-relearn" type="button" data-nfc-relearn-capability="${escapeHtml(capability.id)}" title="覆盖原槽位并重新学习“${escapeHtml(capability.name)}”"><i data-lucide="refresh-cw"></i><span>重新学习</span></button></div>`;
|
||||
}).join("")}</div>
|
||||
<div class="v2-nfc-recognized-actions"><button class="v2-extension-primary" type="button" data-nfc-continue-learning ${firstPending ? "" : "disabled"}><i data-lucide="radio-tower"></i>${firstPending ? "继续学习红外" : "动作已全部学习"}</button><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
|
||||
}
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
}
|
||||
|
||||
function buildUnifiedNfcDialog() {
|
||||
if ($("#extensionNfcDialog")) return;
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "v2-extension-dialog v2-nfc-dialog";
|
||||
dialog.id = "extensionNfcDialog";
|
||||
dialog.innerHTML = `<header><div><span class="section-kicker">NFC · INFRARED LEARNING</span><h2>感应并学习玩具</h2><p>刷卡识别玩具,再通过现有 GSCU1B 链路学习和验证红外动作。</p></div><button type="button" data-close-nfc-dialog aria-label="关闭"><i data-lucide="x"></i></button></header>
|
||||
<section class="v2-nfc-stage" id="extensionNfcStage" data-state="waiting">
|
||||
<header><span><i data-lucide="scan-line"></i></span><div><small>靠近读卡器后</small><h3>玩具 NFC 感应区</h3><p id="extensionNfcReaderStatus">正在检查读卡器</p></div><b id="extensionNfcReaderBadge" data-state="offline">READER CHECK</b></header>
|
||||
<div class="v2-nfc-body" id="extensionNfcBody"></div>
|
||||
</section>
|
||||
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>NFC UID 与玩具动作绑定保存在当前浏览器;红外波形保存在 GSCU1B 物理槽位。</span></footer>`;
|
||||
document.body.append(dialog);
|
||||
}
|
||||
|
||||
async function syncPeripheralCatalog(device = null) {
|
||||
try {
|
||||
await api("display-sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ peripherals: peripheralCatalog(), device }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("外设能力同步失败", error);
|
||||
}
|
||||
}
|
||||
|
||||
function bindCardToDevice(device, cardUid) {
|
||||
if (!device || !cardUid) return;
|
||||
const normalizedUid = normalizeCardUid(cardUid);
|
||||
state.devices.forEach((item) => {
|
||||
if (item.id !== device.id && normalizeCardUid(item.cardUid) === normalizedUid) {
|
||||
item.cardUid = null;
|
||||
}
|
||||
});
|
||||
device.cardUid = normalizedUid;
|
||||
device.toyNumber ||= nextToyNumber();
|
||||
state.selectedDeviceByType[device.type] = device.id;
|
||||
saveState();
|
||||
renderAll();
|
||||
void syncPeripheralCatalog(device);
|
||||
}
|
||||
|
||||
function createToyFromNfc() {
|
||||
const cardUid = nfcSnapshot.card?.uid;
|
||||
const name = $("#extensionNfcToyName")?.value.trim();
|
||||
const typeKey = $("#extensionNfcToyType")?.value;
|
||||
if (!cardUid) return showToast("请先将玩具 NFC 靠近读卡器。", "error");
|
||||
if (!name) return showToast("请输入玩具名称。", "error");
|
||||
if (linkedDeviceForCard(cardUid)) return showToast("这张 NFC 已绑定玩具。", "error");
|
||||
if (state.devices.some((device) => device.name === name)) {
|
||||
return showToast("已经存在同名玩具,请改用其他名称。", "error");
|
||||
}
|
||||
const device = {
|
||||
id: uid("toy"),
|
||||
name,
|
||||
type: TOY_TYPES[typeKey] ? typeKey : "other",
|
||||
aliases: [name],
|
||||
toyNumber: nextToyNumber(),
|
||||
cardUid: normalizeCardUid(cardUid),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const capabilities = seedPresets(device);
|
||||
state.devices.push(device);
|
||||
state.capabilities.push(...capabilities);
|
||||
state.selectedDeviceByType[device.type] = device.id;
|
||||
saveState();
|
||||
renderAll();
|
||||
void syncPeripheralCatalog(device);
|
||||
showToast(`已识别“${device.name}”,请选择遥控动作开始学习。`, "success");
|
||||
if (capabilities[0]) openLearn(capabilities[0].id);
|
||||
}
|
||||
|
||||
function bindCurrentNfcToExisting() {
|
||||
const deviceId = $("#extensionNfcExistingDevice")?.value;
|
||||
const device = state.devices.find((item) => item.id === deviceId);
|
||||
const cardUid = nfcSnapshot.card?.uid;
|
||||
if (!device || !cardUid) return;
|
||||
bindCardToDevice(device, cardUid);
|
||||
showToast(`NFC 已绑定到“${device.name}”。`, "success");
|
||||
}
|
||||
|
||||
function unbindCurrentNfc() {
|
||||
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
|
||||
if (!device) return;
|
||||
device.cardUid = null;
|
||||
saveState();
|
||||
renderAll();
|
||||
void syncPeripheralCatalog(device);
|
||||
showToast(`已解除“${device.name}”的 NFC 绑定。`);
|
||||
}
|
||||
|
||||
function continueNfcLearning(capabilityId = null) {
|
||||
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
|
||||
const capability = capabilityId
|
||||
? state.capabilities.find((item) => item.id === capabilityId)
|
||||
: deviceCapabilities(device?.id).find((item) => item.status !== "learned");
|
||||
if (!device || !capability) {
|
||||
showToast("该玩具没有待学习动作。", "success");
|
||||
return;
|
||||
}
|
||||
state.selectedDeviceByType[device.type] = device.id;
|
||||
saveState();
|
||||
openLearn(capability.id);
|
||||
}
|
||||
|
||||
async function emitNfcCapability(capabilityId) {
|
||||
const capability = learnedCapabilities().find((item) => item.id === capabilityId);
|
||||
const device = state.devices.find((item) => item.id === capability?.deviceId);
|
||||
if (!capability || !device) return;
|
||||
if (capability.risk === "high" && !window.confirm(
|
||||
`“${capability.name}”可能让玩具移动,请确认周围安全。`,
|
||||
)) return;
|
||||
try {
|
||||
await sendGatewayCommand("emit", { slot: capability.slot });
|
||||
showToast(`已向“${device.name}”发射“${capability.name}”。`, "success");
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function applyNfcSnapshot(value, { announce = true } = {}) {
|
||||
if (!value || typeof value !== "object") return;
|
||||
const revision = Number(value.revision ?? -1);
|
||||
if (revision === nfcSnapshot.revision) return;
|
||||
if (Array.isArray(value.legacy_profiles) && value.legacy_profiles.length) {
|
||||
importLegacyProfiles(value.legacy_profiles);
|
||||
}
|
||||
const previousUid = nfcSnapshot.card?.uid || null;
|
||||
nfcSnapshot = {
|
||||
revision,
|
||||
reader: value.reader && typeof value.reader === "object" ? value.reader : {},
|
||||
card: value.card && typeof value.card === "object" ? value.card : null,
|
||||
legacyProfiles: Array.isArray(value.legacy_profiles) ? value.legacy_profiles : [],
|
||||
};
|
||||
if (Array.isArray(value.legacy_bridge_addresses)) {
|
||||
void migrateLegacyProfiles(value.legacy_bridge_addresses);
|
||||
}
|
||||
const currentUid = nfcSnapshot.card?.uid || null;
|
||||
if (currentUid && currentUid !== previousUid) {
|
||||
const device = linkedDeviceForCard(currentUid);
|
||||
if (device) {
|
||||
state.selectedDeviceByType[device.type] = device.id;
|
||||
saveState();
|
||||
if (announce) showToast(`已识别玩具“${device.name}”。`, "success");
|
||||
void syncPeripheralCatalog(device);
|
||||
} else if (announce) {
|
||||
showToast("检测到新的玩具 NFC,请建立或绑定玩具档案。", "success");
|
||||
}
|
||||
}
|
||||
renderNfcDialog();
|
||||
renderUnifiedExtensionView?.();
|
||||
}
|
||||
|
||||
async function refreshNfcStatus() {
|
||||
try {
|
||||
applyNfcSnapshot(await api("nfc/status"));
|
||||
} catch (error) {
|
||||
applyNfcSnapshot({
|
||||
revision: Date.now(),
|
||||
reader: { online: false, state: "error", port: "auto", error: error.message },
|
||||
card: null,
|
||||
}, { announce: false });
|
||||
}
|
||||
}
|
||||
|
||||
function startNfcPolling() {
|
||||
if (nfcPollTimer !== null) return;
|
||||
void refreshNfcStatus();
|
||||
nfcPollTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") void refreshNfcStatus();
|
||||
}, NFC_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopNfcPolling() {
|
||||
if (nfcPollTimer !== null) window.clearInterval(nfcPollTimer);
|
||||
nfcPollTimer = null;
|
||||
}
|
||||
|
||||
function openNfcDialog() {
|
||||
renderNfcDialog();
|
||||
openDialog("extensionNfcDialog");
|
||||
void refreshNfcStatus();
|
||||
}
|
||||
|
||||
function bindUnifiedNfcEvents(mount) {
|
||||
mount.addEventListener("click", (event) => {
|
||||
if (event.target.closest("[data-open-nfc-learning]")) openNfcDialog();
|
||||
});
|
||||
const dialog = $("#extensionNfcDialog");
|
||||
dialog.addEventListener("click", (event) => {
|
||||
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
|
||||
if (event.target.closest("[data-nfc-create-and-learn]")) createToyFromNfc();
|
||||
if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting();
|
||||
if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc();
|
||||
const relearn = event.target.closest("[data-nfc-relearn-capability]");
|
||||
if (relearn) continueNfcLearning(relearn.dataset.nfcRelearnCapability);
|
||||
const learn = event.target.closest("[data-nfc-learn-capability]");
|
||||
if (learn) continueNfcLearning(learn.dataset.nfcLearnCapability);
|
||||
if (event.target.closest("[data-nfc-continue-learning]")) continueNfcLearning();
|
||||
const run = event.target.closest("[data-nfc-run-capability]");
|
||||
if (run) void emitNfcCapability(run.dataset.nfcRunCapability);
|
||||
});
|
||||
$("#extensionContinueLearn").addEventListener("click", () => {
|
||||
try {
|
||||
learning.draft = collectLearningDraft();
|
||||
showLearnPage(2);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error");
|
||||
}
|
||||
});
|
||||
$("#extensionStartLearn").addEventListener("click", startLearning);
|
||||
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
|
||||
$("#extensionTestEmit").addEventListener("click", testLearning);
|
||||
$("#extensionConfirmLearn").addEventListener("click", () => {
|
||||
confirmLearning();
|
||||
void syncPeripheralCatalog(linkedDeviceForCard(nfcSnapshot.card?.uid));
|
||||
});
|
||||
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
|
||||
if (learning.phase === "listening") {
|
||||
event.preventDefault();
|
||||
cancelLearning().then(() => closeDialog("extensionLearnDialog"));
|
||||
}
|
||||
});
|
||||
$$('[data-close-extension-dialog="extensionLearnDialog"]').forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
if (learning.phase === "listening") await cancelLearning();
|
||||
closeDialog("extensionLearnDialog");
|
||||
});
|
||||
});
|
||||
window.addEventListener("pagehide", stopNfcPolling);
|
||||
window.addEventListener("pageshow", startNfcPolling);
|
||||
}
|
||||
|
||||
function buildUnifiedExtensionSync() {
|
||||
const mount = $("#vehicleExtensionSync");
|
||||
if (!mount) return false;
|
||||
@@ -2298,6 +2953,21 @@
|
||||
|
||||
const render = () => {
|
||||
const health = snapshot?.extension_health || {};
|
||||
const embeddedNfc = health.nfc;
|
||||
if (nfcSnapshot.revision < 0 && embeddedNfc) {
|
||||
nfcSnapshot = {
|
||||
revision: Number(embeddedNfc.revision ?? -1),
|
||||
reader: embeddedNfc.reader || {},
|
||||
card: embeddedNfc.card || null,
|
||||
legacyProfiles: Array.isArray(embeddedNfc.legacy_profiles)
|
||||
? embeddedNfc.legacy_profiles
|
||||
: [],
|
||||
};
|
||||
if (nfcSnapshot.legacyProfiles.length) importLegacyProfiles(nfcSnapshot.legacyProfiles);
|
||||
if (Array.isArray(embeddedNfc.legacy_bridge_addresses)) {
|
||||
void migrateLegacyProfiles(embeddedNfc.legacy_bridge_addresses);
|
||||
}
|
||||
}
|
||||
const physicalConnected = health.transport !== "simulation"
|
||||
&& Boolean(health.connected || health.peer_connected);
|
||||
const listening = Boolean(health.listening);
|
||||
@@ -2311,13 +2981,24 @@
|
||||
candidate.id || `${candidate.device_id}:${candidate.action}`
|
||||
) === identity) === index;
|
||||
});
|
||||
const nfcReader = nfcSnapshot.reader || {};
|
||||
const nfcCard = nfcSnapshot.card;
|
||||
const sensedCardUid = normalizeCardUid(nfcCard?.uid);
|
||||
const nfcDevice = linkedDeviceForCard(sensedCardUid);
|
||||
const activeCatalog = sensedCardUid
|
||||
? mergedCatalog.filter((item) => (
|
||||
normalizeCardUid(item.card_uid) === sensedCardUid
|
||||
&& item.status === "learned"
|
||||
))
|
||||
: [];
|
||||
const grouped = new Map();
|
||||
mergedCatalog.forEach((item) => {
|
||||
activeCatalog.forEach((item) => {
|
||||
const deviceId = item.device_id || "external-device";
|
||||
if (!grouped.has(deviceId)) grouped.set(deviceId, {
|
||||
id: deviceId,
|
||||
name: item.device_name || item.type_label || "USB 扩展设备",
|
||||
type: item.type_label || item.device_type || "扩展硬件",
|
||||
cardUid: item.card_uid || null,
|
||||
actions: [],
|
||||
});
|
||||
grouped.get(deviceId).actions.push(item.action || item.name || "已学习能力");
|
||||
@@ -2325,60 +3006,102 @@
|
||||
const devices = [...grouped.values()];
|
||||
const lastAction = snapshot?.extension?.last_action;
|
||||
const lastResult = snapshot?.extension?.result;
|
||||
const nfcCapabilities = nfcDevice ? learnedCapabilities(nfcDevice.id) : [];
|
||||
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
|
||||
const statusLabel = physicalConnected
|
||||
? "USB 外设已接入"
|
||||
: devices.length
|
||||
? "外设目录已同步"
|
||||
const statusLabel = devices.length
|
||||
? "已感应 NFC 外设"
|
||||
: sensedCardUid
|
||||
? "当前 NFC 暂无已学习能力"
|
||||
: nfcReader.online
|
||||
? "等待 NFC 外设靠近"
|
||||
: "NFC 读卡器离线";
|
||||
const gatewayLabel = physicalConnected
|
||||
? "GSCU1B 已连接"
|
||||
: health.transport === "simulation"
|
||||
? "扩展仿真链路"
|
||||
: listening
|
||||
? "等待 USB 外设接入"
|
||||
: "扩展链路检查中";
|
||||
const statusNote = physicalConnected
|
||||
? `${health.peer || "GSCU1B"} · 已同步 ${mergedCatalog.length} 项扩展能力`
|
||||
: devices.length
|
||||
? `车机已同步 ${devices.length} 个外设、${mergedCatalog.length} 项扩展能力`
|
||||
: health.message || "插入外设后,车机将自动识别并同步到此处";
|
||||
? "等待 GSCU1B 接入"
|
||||
: "红外链路检查中";
|
||||
const gatewayNote = physicalConnected
|
||||
? `${health.peer || "GSCU1B"} · 红外学习与发射链路已就绪`
|
||||
: health.message || "红外学习器接入后可学习并发射玩具动作";
|
||||
const visibleLastAction = devices.length ? lastAction : null;
|
||||
const nfcState = !nfcReader.online
|
||||
? "offline"
|
||||
: nfcDevice
|
||||
? "recognized"
|
||||
: nfcCard?.uid
|
||||
? "new-card"
|
||||
: "waiting";
|
||||
const nfcTitle = nfcDevice
|
||||
? `${nfcDevice.toyNumber ? `#${nfcDevice.toyNumber} · ` : ""}${nfcDevice.name}`
|
||||
: nfcCard?.uid
|
||||
? "检测到新的玩具 NFC"
|
||||
: nfcReader.online
|
||||
? "等待玩具靠近"
|
||||
: "NFC 读卡器未连接";
|
||||
const nfcNote = nfcDevice
|
||||
? `${nfcCapabilities.length} 项已学习动作 · ${nfcCard.uid}`
|
||||
: nfcCard?.uid
|
||||
? `${nfcCard.uid} · 点击建立或绑定玩具档案`
|
||||
: nfcReaderNote(nfcReader);
|
||||
mount.dataset.hasDevices = String(devices.length > 0);
|
||||
mount.innerHTML = `
|
||||
<header class="v2-extension-sync-heading">
|
||||
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3></div>
|
||||
<span data-state="${physicalConnected || devices.length ? "online" : listening ? "listening" : "offline"}"><i></i>${statusLabel}</span>
|
||||
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3><button class="v2-extension-nfc-launch" type="button" data-open-nfc-learning><i data-lucide="scan-line"></i><span>感应玩具 NFC</span></button></div>
|
||||
<span data-state="${devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
|
||||
<button type="button" data-panel-toggle="extensions" aria-label="${panelCollapsed ? "展开" : "收起"}外设与扩展能力"><i data-lucide="panel-right-close"></i><span data-panel-action-label>${panelCollapsed ? "展开" : "收起"}</span></button>
|
||||
</header>
|
||||
<section class="v2-nfc-inline" data-state="${nfcState}">
|
||||
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
|
||||
<div><small>靠近读卡器后</small><strong>${escapeHtml(nfcTitle)}</strong><p>${escapeHtml(nfcNote)}</p>${nfcCapabilities.length ? `<div>${nfcCapabilities.slice(0, 6).map((capability) => `<i>${escapeHtml(capability.name)}</i>`).join("")}</div>` : ""}</div>
|
||||
<button type="button" data-open-nfc-learning>${nfcDevice ? "查看能力" : nfcCard?.uid ? "建立档案" : "开始感应"}</button>
|
||||
</section>
|
||||
<div class="v2-extension-gateway">
|
||||
<span><i data-lucide="usb"></i></span>
|
||||
<div><strong>${statusLabel}</strong><small>${escapeHtml(statusNote)}</small></div>
|
||||
<div><strong>${gatewayLabel}</strong><small>${escapeHtml(gatewayNote)}</small></div>
|
||||
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
|
||||
</div>
|
||||
<div class="v2-extension-device-list">
|
||||
${devices.length ? devices.map((device) => `
|
||||
<article class="v2-extension-synced-device">
|
||||
<header><span><i data-lucide="cpu"></i></span><div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.type)}</small></div><b>${device.actions.length} 项能力</b></header>
|
||||
<header><span><i data-lucide="${device.cardUid ? "badge-check" : "cpu"}"></i></span><div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.type)}${device.cardUid ? " · 已绑定 NFC" : ""}</small></div><b>${device.actions.length} 项能力</b></header>
|
||||
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
|
||||
</article>`).join("") : `
|
||||
<div class="v2-extension-empty">
|
||||
<i data-lucide="cable"></i><div><strong>暂无已识别外设</strong><span>外设名称、类型和已学习能力将由车机同步显示</span></div>
|
||||
<i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无可显示能力" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "仅显示已绑定并完成红外学习的动作" : "将已绑定且已学习的 NFC 外设靠近读卡器后显示能力"}</span></div>
|
||||
</div>`}
|
||||
</div>
|
||||
<footer class="v2-extension-linkage" data-active="${String(Boolean(lastAction))}">
|
||||
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(lastAction?.command || lastAction?.type || "等待外设与车端设备联动")}</strong></div>
|
||||
<span>${escapeHtml(lastResult?.status || lastResult?.message || "STANDBY")}</span>
|
||||
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
|
||||
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(visibleLastAction?.command || visibleLastAction?.type || "等待 NFC 外设感应")}</strong></div>
|
||||
<span>${escapeHtml(visibleLastAction ? (lastResult?.status || lastResult?.message || "STANDBY") : "STANDBY")}</span>
|
||||
</footer>`;
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
};
|
||||
renderUnifiedExtensionView = render;
|
||||
|
||||
window.addEventListener("cognitive-agent:display-sync", (event) => {
|
||||
snapshot = event.detail || null;
|
||||
if (snapshot?.extension_health?.nfc) {
|
||||
applyNfcSnapshot(snapshot.extension_health.nfc, { announce: false });
|
||||
}
|
||||
render();
|
||||
});
|
||||
buildDialogs();
|
||||
buildUnifiedNfcDialog();
|
||||
bindUnifiedNfcEvents(mount);
|
||||
render();
|
||||
renderNfcDialog();
|
||||
startNfcPolling();
|
||||
void syncPeripheralCatalog();
|
||||
return true;
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (buildUnifiedExtensionSync()) return;
|
||||
if (buildUnifiedExtensionSync()) {
|
||||
void migrateLegacyProfiles();
|
||||
return;
|
||||
}
|
||||
if (!buildShell()) return;
|
||||
initializeDesktopPet();
|
||||
bindEvents();
|
||||
@@ -2386,6 +3109,7 @@
|
||||
setMode(activeMode);
|
||||
if (document.body.dataset.module === "self-extension") refreshHealth();
|
||||
startHealthPolling();
|
||||
void migrateLegacyProfiles();
|
||||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v204";
|
||||
const CACHE_VERSION = "cognitive-agent-pad-v210";
|
||||
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=sync-layout-43",
|
||||
"/self-extension.css?v=sync-layout-26",
|
||||
"/self-extension.js?v=sync-layout-26",
|
||||
"/self-extension.css?v=nfc-toy-33",
|
||||
"/self-extension.js?v=nfc-toy-33",
|
||||
"/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