diff --git a/apps/web-console/README.md b/apps/web-console/README.md index bc4eb27..c016749 100755 --- a/apps/web-console/README.md +++ b/apps/web-console/README.md @@ -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` 一致。 diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 11dcc36..2767b36 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -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 = `
${executionTitle}${executionDetail}
`; $("#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 diff --git a/apps/web-console/index.html b/apps/web-console/index.html index ed235ec..4014b07 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -11,7 +11,7 @@ 511工程-智能座舱 - + @@ -21,7 +21,7 @@ - +
diff --git a/apps/web-console/self-extension.css b/apps/web-console/self-extension.css index 0dd80bc..33c3efa 100644 --- a/apps/web-console/self-extension.css +++ b/apps/web-console/self-extension.css @@ -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; + } +} diff --git a/apps/web-console/self-extension.js b/apps/web-console/self-extension.js index ba2db0b..34090ae 100644 --- a/apps/web-console/self-extension.js +++ b/apps/web-console/self-extension.js @@ -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 = ``; $("#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 = `
NFC READER读卡器尚未就绪

${escapeHtml(nfcReaderNote(reader))}

`; + } else if (!card?.uid) { + stage.dataset.state = "waiting"; + body.innerHTML = `
WAITING FOR TOY请将玩具 NFC 靠近读卡器

让卡片在读卡区停留约 1 秒;识别后会自动显示该玩具及其动作能力。

`; + } else if (!linkedDevice) { + const suggestedNumber = nextToyNumber(); + stage.dataset.state = "new-card"; + body.innerHTML = `
检测到新玩具卡片${escapeHtml(card.uid)}

这张 NFC 尚未绑定,可新建玩具档案或关联到已有玩具。

+
+ + +
+
+ ${state.devices.length ? `
或绑定到已有玩具
` : ""}`; + } 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 = `
+ +
已识别并绑定 · ${escapeHtml(card.uid)}${linkedDevice.toyNumber ? `#${escapeHtml(linkedDevice.toyNumber)} · ` : ""}${escapeHtml(linkedDevice.name)}

${escapeHtml(type.label)} · ${learned.length} 项动作可执行

+ +
+
TOY ACTION CAPABILITIES玩具动作能力
${learned.length}/${capabilities.length} 已学习
+
${capabilities.map((capability) => { + const isLearned = learned.some((item) => item.id === capability.id); + if (!isLearned) { + return `
`; + } + return `
`; + }).join("")}
+
`; + } + 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 = `
NFC · INFRARED LEARNING

感应并学习玩具

刷卡识别玩具,再通过现有 GSCU1B 链路学习和验证红外动作。

+
+
靠近读卡器后

玩具 NFC 感应区

正在检查读卡器

READER CHECK
+
+
+ `; + 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 = `
-
USB EXTENSION · LIVE

外设与扩展能力

- ${statusLabel} +
USB EXTENSION · LIVE

外设与扩展能力

+ ${statusLabel}
+
+ +
靠近读卡器后${escapeHtml(nfcTitle)}

${escapeHtml(nfcNote)}

${nfcCapabilities.length ? `
${nfcCapabilities.slice(0, 6).map((capability) => `${escapeHtml(capability.name)}`).join("")}
` : ""}
+ +
-
${statusLabel}${escapeHtml(statusNote)}
+
${gatewayLabel}${escapeHtml(gatewayNote)}
${String(health.transport || "GSCU").toUpperCase()}
${devices.length ? devices.map((device) => `
-
${escapeHtml(device.name)}${escapeHtml(device.type)}
${device.actions.length} 项能力
+
${escapeHtml(device.name)}${escapeHtml(device.type)}${device.cardUid ? " · 已绑定 NFC" : ""}
${device.actions.length} 项能力
${device.actions.slice(0, 8).map((action) => `${escapeHtml(action)}`).join("")}
`).join("") : `
-
暂无已识别外设外设名称、类型和已学习能力将由车机同步显示
+
${sensedCardUid ? "当前 NFC 暂无可显示能力" : "当前未感应到外设"}${sensedCardUid ? "仅显示已绑定并完成红外学习的动作" : "将已绑定且已学习的 NFC 外设靠近读卡器后显示能力"}
`}
-