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 = `
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 = `
+
+ `;
+ 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 = `
+
+
+ 靠近读卡器后${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) => `
-
+
${device.actions.slice(0, 8).map((action) => `${escapeHtml(action)}`).join("")}
`).join("") : `
-
暂无已识别外设外设名称、类型和已学习能力将由车机同步显示
+
${sensedCardUid ? "当前 NFC 暂无可显示能力" : "当前未感应到外设"}${sensedCardUid ? "仅显示已绑定并完成红外学习的动作" : "将已绑定且已学习的 NFC 外设靠近读卡器后显示能力"}
`}
-