feat: integrate NFC infrared peripherals and orchestration

This commit is contained in:
vee
2026-08-25 11:45:45 +08:00
parent 9ac8fdface
commit 2529b4896a
19 changed files with 3818 additions and 117 deletions
+757 -33
View File
@@ -14,6 +14,12 @@
const MAX_TOTAL_PLAN_DELAY_MS = 60000;
const HEALTH_POLL_INTERVAL_MS = 3000;
const HEALTH_REQUEST_TIMEOUT_MS = 5000;
const NFC_POLL_INTERVAL_MS = 700;
const LEGACY_PROFILE_KEY = "ir-lab-profiles-v2";
const LEGACY_PROFILE_BRIDGE_URLS = [
"http://localhost:3001/n7web-profile-bridge.html",
"http://127.0.0.1:3001/n7web-profile-bridge.html",
];
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
@@ -360,6 +366,17 @@
let healthRefreshPromise = null;
let healthPollTimer = null;
let healthToastRequested = false;
let nfcSnapshot = {
revision: -1,
reader: { online: false, state: "starting", port: "auto", error: null },
card: null,
legacyProfiles: [],
};
let nfcPollTimer = null;
let legacySameOriginChecked = false;
let legacyMigrationCompleted = false;
const legacyBridgeAttempts = new Set();
let renderUnifiedExtensionView = null;
let latestVehicleStateTurn = -1;
let sharedTwinBand = null;
let sharedTwinHome = null;
@@ -393,6 +410,31 @@
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function infraredSignalFromResponse(value) {
if (!value || typeof value !== "object") return null;
for (const [key, child] of Object.entries(value)) {
if (["ir_data", "irData", "infraredData"].includes(key)) {
if (typeof child === "string" && child.trim().length > 4) return child.trim();
if (Array.isArray(child) && child.length) return JSON.stringify(child);
}
const nested = infraredSignalFromResponse(child);
if (nested) return nested;
}
return null;
}
async function infraredFingerprint(signal) {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(signal),
);
return Array.from(new Uint8Array(digest))
.slice(0, 6)
.map((value) => value.toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
}
function validSlot(value) {
const slot = Number(value);
return Number.isInteger(slot) && slot >= SLOT_MIN && slot <= SLOT_MAX;
@@ -476,6 +518,7 @@
countdownTimer: null,
abortController: null,
tested: false,
fingerprint: null,
};
}
@@ -857,13 +900,19 @@
}
function renderAll() {
renderMetrics();
renderHardwareGrid();
renderControl();
renderTypeChooser();
if ($("#extensionHardwareCount")) renderMetrics();
if ($("#extensionHardwareGrid")) renderHardwareGrid();
if ($("#extensionLearnedGrid")) renderControl();
if ($("#extensionTypeChooser")) renderTypeChooser();
if ($("#extensionNfcDialog")) renderNfcDialog();
renderUnifiedExtensionView?.();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function normalizeCardUid(value) {
return String(value || "").replace(/[^0-9a-f]/gi, "").toUpperCase();
}
function renderMetrics() {
$("#extensionHardwareCount").textContent = String(state.devices.length);
$("#extensionCapabilityCount").textContent = String(learnedCapabilities().length);
@@ -1070,16 +1119,33 @@
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
const type = TOY_TYPES[device.type] || TOY_TYPES.other;
const relearning = capability.status === "learned"
&& capability.verified
&& validSlot(capability.slot);
const targetSlot = relearning ? Number(capability.slot) : allocateSlot();
resetLearnStatus();
learning.draft = { capabilityId: capability.id, deviceId: device.id, slot: allocateSlot() };
$("#extensionLearnTitle").textContent = `学习“${capability.name}”`;
$("#extensionLearnPath").textContent = `${type.label} · ${device.name}`;
learning.draft = {
capabilityId: capability.id,
deviceId: device.id,
slot: targetSlot,
relearning,
};
$("#extensionLearnTitle").textContent = `${relearning ? "重新学习" : "学习"}“${capability.name}”`;
$("#extensionLearnPath").textContent = relearning
? `${type.label} · ${device.name} · 将覆盖 IR ${String(targetSlot).padStart(3, "0")} 原信号`
: `${type.label} · ${device.name}`;
$("#extensionLearnIcon").innerHTML = `<i data-lucide="${type.icon}"></i>`;
$("#extensionLearnDevice").textContent = device.name;
$("#extensionLearnAction").textContent = capability.name;
$("#extensionLearnAction").textContent = `${capability.name}${relearning ? "(重新学习)" : ""}`;
$("#extensionLearnAliases").value = (capability.aliases || []).join("、");
$("#extensionLearnRisk").value = capability.risk || "low";
$("#extensionLearnSlot").value = allocateSlot() || "";
$("#extensionLearnSlot").value = targetSlot || "";
$("#extensionContinueLearn").textContent = relearning
? "继续并覆盖原红外信号"
: "继续接收遥控信号";
$("#extensionConfirmLearn").textContent = relearning
? "确认并完成重新学习"
: "确认并完成学习";
showLearnPage(1);
openDialog("extensionLearnDialog");
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
@@ -1151,11 +1217,33 @@
if (countdown <= 0) window.clearInterval(learning.countdownTimer);
}, 1000);
try {
await sendGatewayCommand(
const learnResult = await sendGatewayCommand(
"learn",
{ slot: learning.draft.slot },
learning.abortController.signal,
);
let learnedSignal = null;
let readError = null;
await delay(700);
for (let attempt = 0; attempt < 3 && !learnedSignal; attempt += 1) {
try {
const readResult = await sendGatewayCommand("read", { slot: learning.draft.slot });
learnedSignal = infraredSignalFromResponse(readResult);
} catch (error) {
readError = error;
}
if (!learnedSignal && attempt < 2) await delay(900);
}
learnedSignal ||= infraredSignalFromResponse(learnResult);
if (!learnedSignal) {
throw new Error(
readError?.message
? `学习后无法回读槽位:${readError.message}`
: "学习回执成功,但物理槽位回读为空;请对准学习器并短按遥控器按键。",
);
}
learning.fingerprint = await infraredFingerprint(learnedSignal);
learning.signalBytes = learnedSignal.length;
window.clearInterval(learning.countdownTimer);
learning.phase = "learned";
$("#extensionLearnAnimation").dataset.state = "success";
@@ -1213,6 +1301,8 @@
(item) => item.id === learning.draft.capabilityId,
);
if (!capability) return;
const relearning = Boolean(learning.draft.relearning);
const learnedAt = new Date().toISOString();
Object.assign(capability, {
aliases: [...new Set([capability.name, ...learning.draft.aliases])],
risk: learning.draft.risk,
@@ -1220,13 +1310,22 @@
status: "learned",
verified: true,
enabled: true,
learnedAt: new Date().toISOString(),
fingerprint: learning.fingerprint || capability.fingerprint || null,
signalBytes: learning.signalBytes || capability.signalBytes || null,
learnedAt,
relearnedAt: relearning ? learnedAt : capability.relearnedAt || null,
signalSource: "n7web",
});
state.sessionStartedAt ||= new Date().toISOString();
saveState();
closeDialog("extensionLearnDialog");
renderAll();
showToast(`“${capability.name}”已学习完成。`, "success");
showToast(
relearning
? `“${capability.name}”已重新学习并覆盖 IR ${String(capability.slot).padStart(3, "0")}。`
: `“${capability.name}”已学习完成。`,
"success",
);
learning = freshLearningSession();
}
@@ -1255,10 +1354,13 @@
device_id: device?.id,
device_name: device?.name,
device_type: device?.type,
card_uid: device?.cardUid || null,
toy_number: device?.toyNumber || null,
type_label: type.label,
action: capability.name,
aliases: capability.aliases || [],
risk: capability.risk || "low",
slot: capability.slot,
};
}).filter((item) => item.device_id);
}
@@ -2291,6 +2393,559 @@
});
}
function linkedDeviceForCard(uidValue) {
const uidValueNormalized = normalizeCardUid(uidValue);
if (!uidValueNormalized) return null;
return state.devices.find(
(device) => normalizeCardUid(device.cardUid) === uidValueNormalized,
) || null;
}
function inferLegacyToyType(profile) {
const text = [
profile?.name,
profile?.model,
...(Array.isArray(profile?.actions)
? profile.actions.flatMap((action) => [action?.name, action?.code])
: []),
].join(" ").toLowerCase();
if (/(氛围|灯|亮度|颜色|rgb|light|colour|color)/i.test(text)) return "light";
if (/(声音|音乐|音量|播放|sound|music|volume)/i.test(text)) return "sound";
if (/(玩偶|娃娃|唱歌|说话|doll)/i.test(text)) return "doll";
if (/(机械|升降|伸缩|正转|反转|mechanical)/i.test(text)) return "mechanical";
if (/(前进|后退|左转|右转|移动|小车|action|motion)/i.test(text)) return "action";
return "other";
}
function legacyIdentifier(prefix, value) {
const suffix = String(value || "profile")
.replace(/[^a-z0-9_-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64) || "profile";
let candidate = `${prefix}-${suffix}`;
let index = 2;
while (state.devices.some((device) => device.id === candidate)) {
candidate = `${prefix}-${suffix}-${index}`;
index += 1;
}
return candidate;
}
function legacyCapabilityRisk(action) {
const text = `${action?.name || ""} ${action?.code || ""}`;
return /(前进|后退|左转|右转|移动|旋转|升降|伸缩)/.test(text) ? "high" : "low";
}
function importLegacyProfiles(value) {
if (!Array.isArray(value)) return { devices: 0, actions: 0 };
let importedDevices = 0;
let importedActions = 0;
let changed = false;
value.forEach((profile, profileIndex) => {
if (!profile || typeof profile !== "object") return;
const legacyActions = Array.isArray(profile.actions)
? profile.actions.filter((action) => action?.learned && validSlot(action.slot))
: [];
const cardUid = normalizeCardUid(profile.cardUid);
if (!cardUid && !legacyActions.length) return;
const legacyProfileId = String(profile.id || `profile-${profileIndex}`);
const type = inferLegacyToyType(profile);
let device = cardUid ? linkedDeviceForCard(cardUid) : null;
device ||= state.devices.find((item) => item.legacyProfileId === legacyProfileId) || null;
if (!device) {
device = {
id: legacyIdentifier("legacy-toy", legacyProfileId),
name: String(profile.name || `玩具 ${profile.toyNumber || nextToyNumber()}`),
type,
aliases: [String(profile.name || "玩具")],
toyNumber: String(profile.toyNumber || nextToyNumber()),
cardUid: cardUid || null,
model: String(profile.model || "ir-toy-learning 导入外设"),
legacyProfileId,
importedFrom: "ir-toy-learning",
createdAt: new Date().toISOString(),
};
state.devices.push(device);
importedDevices += 1;
changed = true;
} else {
const before = JSON.stringify([
device.name,
device.type,
device.toyNumber,
device.cardUid,
device.legacyProfileId,
]);
if (!device.cardUid && cardUid) device.cardUid = cardUid;
if (!device.toyNumber && profile.toyNumber) device.toyNumber = String(profile.toyNumber);
if (!device.legacyProfileId) device.legacyProfileId = legacyProfileId;
if (device.type === "other" && type !== "other") device.type = type;
if (/^玩具\s*\d+$/i.test(device.name) && profile.name) device.name = String(profile.name);
device.importedFrom ||= "ir-toy-learning";
changed ||= before !== JSON.stringify([
device.name,
device.type,
device.toyNumber,
device.cardUid,
device.legacyProfileId,
]);
}
state.selectedDeviceByType[device.type] = device.id;
legacyActions.forEach((action, actionIndex) => {
const slot = Number(action.slot);
const name = String(action.name || action.code || `红外动作 ${slot}`);
const legacyActionId = String(action.id || `${legacyProfileId}:${actionIndex}:${slot}`);
const aliases = [...new Set([
name,
typeof action.code === "string" ? action.code : "",
].filter(Boolean))];
let capability = state.capabilities.find((item) => (
item.deviceId === device.id
&& (item.legacyActionId === legacyActionId
|| item.name === name)
));
if (!capability) {
capability = {
id: uid("legacy-cap"),
deviceId: device.id,
key: `legacy_${slot}`,
createdAt: new Date().toISOString(),
};
state.capabilities.push(capability);
importedActions += 1;
changed = true;
}
// A signal learned again in N7Web is authoritative. Repeated NFC snapshots
// may still contain the old ir-toy-learning metadata and must not roll it back.
if (capability.signalSource === "n7web") return;
const before = JSON.stringify(capability);
Object.assign(capability, {
name,
aliases,
category: type === "light" ? "effect" : "custom",
risk: legacyCapabilityRisk(action),
slot,
status: "learned",
verified: true,
enabled: true,
fingerprint: action.fingerprint || capability.fingerprint || null,
learnedAt: action.updatedAt || capability.learnedAt || new Date().toISOString(),
legacyActionId,
importedFrom: "ir-toy-learning",
});
changed ||= before !== JSON.stringify(capability);
});
});
if (changed) {
state.sessionStartedAt ||= new Date().toISOString();
saveState();
renderAll();
void syncPeripheralCatalog();
}
return { devices: importedDevices, actions: importedActions };
}
function readSameOriginLegacyProfiles() {
try {
return JSON.parse(localStorage.getItem(LEGACY_PROFILE_KEY) || "null");
} catch (_error) {
return null;
}
}
function requestLegacyProfiles(bridgeUrl) {
return new Promise((resolve) => {
const iframe = document.createElement("iframe");
const bridgeOrigin = new URL(bridgeUrl).origin;
const requestId = uid("legacy-import");
let settled = false;
const cleanup = (profiles = null) => {
if (settled) return;
settled = true;
window.clearTimeout(timer);
window.removeEventListener("message", receive);
iframe.remove();
resolve(profiles);
};
const request = () => iframe.contentWindow?.postMessage({
type: "N7WEB_REQUEST_IR_TOY_PROFILES",
requestId,
}, bridgeOrigin);
const receive = (event) => {
if (event.origin !== bridgeOrigin || event.source !== iframe.contentWindow) return;
if (event.data?.type === "IR_TOY_LEGACY_BRIDGE_READY") {
request();
return;
}
if (event.data?.type !== "IR_TOY_LEGACY_PROFILES"
|| event.data.requestId !== requestId) return;
cleanup(Array.isArray(event.data.profiles) ? event.data.profiles : []);
};
const timer = window.setTimeout(() => cleanup(), 1800);
iframe.src = bridgeUrl;
iframe.hidden = true;
iframe.setAttribute("aria-hidden", "true");
iframe.addEventListener("load", request, { once: true });
window.addEventListener("message", receive);
document.body.append(iframe);
});
}
function hasImportableLegacyProfiles(profiles) {
return Array.isArray(profiles) && profiles.some((profile) => (
normalizeCardUid(profile?.cardUid)
|| (Array.isArray(profile?.actions)
&& profile.actions.some((action) => action?.learned && validSlot(action.slot)))
));
}
function legacyBridgeUrls(addresses = []) {
const currentHostUrl = window.location.hostname
? `http://${window.location.hostname}:3001/n7web-profile-bridge.html`
: null;
const addressUrls = (Array.isArray(addresses) ? addresses : [])
.filter((address) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(String(address)))
.map((address) => `http://${address}:3001/n7web-profile-bridge.html`);
return [...new Set([currentHostUrl, ...LEGACY_PROFILE_BRIDGE_URLS, ...addressUrls].filter(Boolean))];
}
async function migrateLegacyProfiles(addresses = []) {
if (legacyMigrationCompleted) return;
if (!legacySameOriginChecked) {
legacySameOriginChecked = true;
const sameOriginProfiles = readSameOriginLegacyProfiles();
if (hasImportableLegacyProfiles(sameOriginProfiles)) {
const imported = importLegacyProfiles(sameOriginProfiles);
legacyMigrationCompleted = true;
if (imported.devices || imported.actions) {
showToast(`已迁移 ${imported.devices} 个玩具、${imported.actions} 项红外动作。`, "success");
}
return;
}
}
for (const bridgeUrl of legacyBridgeUrls(addresses)) {
if (legacyBridgeAttempts.has(bridgeUrl)) continue;
legacyBridgeAttempts.add(bridgeUrl);
const profiles = await requestLegacyProfiles(bridgeUrl);
if (!hasImportableLegacyProfiles(profiles)) continue;
const imported = importLegacyProfiles(profiles);
legacyMigrationCompleted = true;
if (imported.devices || imported.actions) {
showToast(`已从 ir-toy-learning 迁移 ${imported.devices} 个玩具、${imported.actions} 项红外动作。`, "success");
}
return;
}
}
function nextToyNumber() {
const highest = state.devices.reduce((current, device) => {
const parsed = Number.parseInt(device.toyNumber || "", 10);
return Number.isFinite(parsed) ? Math.max(current, parsed) : current;
}, 0);
return String(highest + 1).padStart(3, "0");
}
function nfcReaderNote(reader = nfcSnapshot.reader) {
if (reader?.online) {
const firmware = reader.firmware ? ` · PN532 ${reader.firmware}` : "";
return `${reader.port || "串口"} 已就绪${firmware}`;
}
if (reader?.state === "connecting" || reader?.state === "starting") {
return "正在查找 PN532 / PCR532 读卡器";
}
return reader?.error || "请连接 NFC 读卡器并确认 CH340 / CH341 驱动可用";
}
function renderNfcDialog() {
const stage = $("#extensionNfcStage");
const body = $("#extensionNfcBody");
const status = $("#extensionNfcReaderStatus");
const badge = $("#extensionNfcReaderBadge");
if (!stage || !body || !status || !badge) return;
const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card;
const linkedDevice = linkedDeviceForCard(card?.uid);
status.textContent = nfcReaderNote(reader);
badge.textContent = reader.online ? "READER READY" : "READER OFFLINE";
badge.dataset.state = reader.online ? "online" : "offline";
if (!reader.online) {
stage.dataset.state = "offline";
body.innerHTML = `<div class="v2-nfc-waiting offline"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>NFC READER</small><strong>读卡器尚未就绪</strong><p>${escapeHtml(nfcReaderNote(reader))}</p></div></div>`;
} else if (!card?.uid) {
stage.dataset.state = "waiting";
body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><small>WAITING FOR TOY</small><strong>请将玩具 NFC 靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后会自动显示该玩具及其动作能力。</p></div></div>`;
} else if (!linkedDevice) {
const suggestedNumber = nextToyNumber();
stage.dataset.state = "new-card";
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到新玩具卡片</small><strong>${escapeHtml(card.uid)}</strong><p>这张 NFC 尚未绑定,可新建玩具档案或关联到已有玩具。</p></div></div>
<div class="v2-nfc-create-grid">
<label><span>玩具名称</span><input id="extensionNfcToyName" maxlength="48" value="玩具 ${suggestedNumber}" /></label>
<label><span>玩具类别</span><select id="extensionNfcToyType">${Object.entries(TOY_TYPES).map(([key, type]) => `<option value="${escapeHtml(key)}">${escapeHtml(type.label)}</option>`).join("")}</select></label>
</div>
<div class="v2-nfc-primary-actions"><button class="v2-extension-primary" type="button" data-nfc-create-and-learn><i data-lucide="radio-tower"></i>建立档案并开始学习</button></div>
${state.devices.length ? `<div class="v2-nfc-existing"><span>或绑定到已有玩具</span><div><select id="extensionNfcExistingDevice">${state.devices.map((device) => `<option value="${escapeHtml(device.id)}">${device.toyNumber ? `#${escapeHtml(device.toyNumber)} · ` : ""}${escapeHtml(device.name)}</option>`).join("")}</select><button type="button" data-nfc-bind-existing>绑定</button></div></div>` : ""}`;
} else {
const type = TOY_TYPES[linkedDevice.type] || TOY_TYPES.other;
const capabilities = deviceCapabilities(linkedDevice.id);
const learned = learnedCapabilities(linkedDevice.id);
const firstPending = capabilities.find((capability) => capability.status !== "learned");
stage.dataset.state = "recognized";
body.innerHTML = `<div class="v2-nfc-recognized-heading">
<span data-extension-type="${escapeHtml(linkedDevice.type || "other")}"><i data-lucide="${type.icon}"></i></span>
<div><small>已识别并绑定 · ${escapeHtml(card.uid)}</small><strong>${linkedDevice.toyNumber ? `#${escapeHtml(linkedDevice.toyNumber)} · ` : ""}${escapeHtml(linkedDevice.name)}</strong><p>${escapeHtml(type.label)} · ${learned.length} 项动作可执行</p></div>
<i data-lucide="badge-check"></i>
</div>
<div class="v2-nfc-capability-heading"><div><small>TOY ACTION CAPABILITIES</small><strong>玩具动作能力</strong></div><span>${learned.length}/${capabilities.length} 已学习</span></div>
<div class="v2-nfc-capability-grid">${capabilities.map((capability) => {
const isLearned = learned.some((item) => item.id === capability.id);
if (!isLearned) {
return `<div class="v2-nfc-capability-card pending"><button class="v2-nfc-capability-primary" type="button" data-nfc-learn-capability="${escapeHtml(capability.id)}"><span>${escapeHtml(capability.name)}</span><small>待学习 · 点击录入</small><i data-lucide="plus"></i></button></div>`;
}
return `<div class="v2-nfc-capability-card learned"><button class="v2-nfc-capability-primary" type="button" data-nfc-run-capability="${escapeHtml(capability.id)}"><span>${escapeHtml(capability.name)}</span><small>IR ${String(capability.slot).padStart(3, "0")} · 点击发射</small><i data-lucide="send"></i></button><button class="v2-nfc-relearn" type="button" data-nfc-relearn-capability="${escapeHtml(capability.id)}" title="覆盖原槽位并重新学习“${escapeHtml(capability.name)}”"><i data-lucide="refresh-cw"></i><span>重新学习</span></button></div>`;
}).join("")}</div>
<div class="v2-nfc-recognized-actions"><button class="v2-extension-primary" type="button" data-nfc-continue-learning ${firstPending ? "" : "disabled"}><i data-lucide="radio-tower"></i>${firstPending ? "继续学习红外" : "动作已全部学习"}</button><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function buildUnifiedNfcDialog() {
if ($("#extensionNfcDialog")) return;
const dialog = document.createElement("dialog");
dialog.className = "v2-extension-dialog v2-nfc-dialog";
dialog.id = "extensionNfcDialog";
dialog.innerHTML = `<header><div><span class="section-kicker">NFC · INFRARED LEARNING</span><h2>感应并学习玩具</h2><p>刷卡识别玩具,再通过现有 GSCU1B 链路学习和验证红外动作。</p></div><button type="button" data-close-nfc-dialog aria-label="关闭"><i data-lucide="x"></i></button></header>
<section class="v2-nfc-stage" id="extensionNfcStage" data-state="waiting">
<header><span><i data-lucide="scan-line"></i></span><div><small>靠近读卡器后</small><h3>玩具 NFC 感应区</h3><p id="extensionNfcReaderStatus">正在检查读卡器</p></div><b id="extensionNfcReaderBadge" data-state="offline">READER CHECK</b></header>
<div class="v2-nfc-body" id="extensionNfcBody"></div>
</section>
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>NFC UID 与玩具动作绑定保存在当前浏览器;红外波形保存在 GSCU1B 物理槽位。</span></footer>`;
document.body.append(dialog);
}
async function syncPeripheralCatalog(device = null) {
try {
await api("display-sync", {
method: "POST",
body: JSON.stringify({ peripherals: peripheralCatalog(), device }),
});
} catch (error) {
console.warn("外设能力同步失败", error);
}
}
function bindCardToDevice(device, cardUid) {
if (!device || !cardUid) return;
const normalizedUid = normalizeCardUid(cardUid);
state.devices.forEach((item) => {
if (item.id !== device.id && normalizeCardUid(item.cardUid) === normalizedUid) {
item.cardUid = null;
}
});
device.cardUid = normalizedUid;
device.toyNumber ||= nextToyNumber();
state.selectedDeviceByType[device.type] = device.id;
saveState();
renderAll();
void syncPeripheralCatalog(device);
}
function createToyFromNfc() {
const cardUid = nfcSnapshot.card?.uid;
const name = $("#extensionNfcToyName")?.value.trim();
const typeKey = $("#extensionNfcToyType")?.value;
if (!cardUid) return showToast("请先将玩具 NFC 靠近读卡器。", "error");
if (!name) return showToast("请输入玩具名称。", "error");
if (linkedDeviceForCard(cardUid)) return showToast("这张 NFC 已绑定玩具。", "error");
if (state.devices.some((device) => device.name === name)) {
return showToast("已经存在同名玩具,请改用其他名称。", "error");
}
const device = {
id: uid("toy"),
name,
type: TOY_TYPES[typeKey] ? typeKey : "other",
aliases: [name],
toyNumber: nextToyNumber(),
cardUid: normalizeCardUid(cardUid),
createdAt: new Date().toISOString(),
};
const capabilities = seedPresets(device);
state.devices.push(device);
state.capabilities.push(...capabilities);
state.selectedDeviceByType[device.type] = device.id;
saveState();
renderAll();
void syncPeripheralCatalog(device);
showToast(`已识别“${device.name}”,请选择遥控动作开始学习。`, "success");
if (capabilities[0]) openLearn(capabilities[0].id);
}
function bindCurrentNfcToExisting() {
const deviceId = $("#extensionNfcExistingDevice")?.value;
const device = state.devices.find((item) => item.id === deviceId);
const cardUid = nfcSnapshot.card?.uid;
if (!device || !cardUid) return;
bindCardToDevice(device, cardUid);
showToast(`NFC 已绑定到“${device.name}”。`, "success");
}
function unbindCurrentNfc() {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
device.cardUid = null;
saveState();
renderAll();
void syncPeripheralCatalog(device);
showToast(`已解除“${device.name}”的 NFC 绑定。`);
}
function continueNfcLearning(capabilityId = null) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
const capability = capabilityId
? state.capabilities.find((item) => item.id === capabilityId)
: deviceCapabilities(device?.id).find((item) => item.status !== "learned");
if (!device || !capability) {
showToast("该玩具没有待学习动作。", "success");
return;
}
state.selectedDeviceByType[device.type] = device.id;
saveState();
openLearn(capability.id);
}
async function emitNfcCapability(capabilityId) {
const capability = learnedCapabilities().find((item) => item.id === capabilityId);
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
if (capability.risk === "high" && !window.confirm(
`“${capability.name}”可能让玩具移动,请确认周围安全。`,
)) return;
try {
await sendGatewayCommand("emit", { slot: capability.slot });
showToast(`已向“${device.name}”发射“${capability.name}”。`, "success");
} catch (error) {
showToast(error.message, "error");
}
}
function applyNfcSnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return;
const revision = Number(value.revision ?? -1);
if (revision === nfcSnapshot.revision) return;
if (Array.isArray(value.legacy_profiles) && value.legacy_profiles.length) {
importLegacyProfiles(value.legacy_profiles);
}
const previousUid = nfcSnapshot.card?.uid || null;
nfcSnapshot = {
revision,
reader: value.reader && typeof value.reader === "object" ? value.reader : {},
card: value.card && typeof value.card === "object" ? value.card : null,
legacyProfiles: Array.isArray(value.legacy_profiles) ? value.legacy_profiles : [],
};
if (Array.isArray(value.legacy_bridge_addresses)) {
void migrateLegacyProfiles(value.legacy_bridge_addresses);
}
const currentUid = nfcSnapshot.card?.uid || null;
if (currentUid && currentUid !== previousUid) {
const device = linkedDeviceForCard(currentUid);
if (device) {
state.selectedDeviceByType[device.type] = device.id;
saveState();
if (announce) showToast(`已识别玩具“${device.name}”。`, "success");
void syncPeripheralCatalog(device);
} else if (announce) {
showToast("检测到新的玩具 NFC,请建立或绑定玩具档案。", "success");
}
}
renderNfcDialog();
renderUnifiedExtensionView?.();
}
async function refreshNfcStatus() {
try {
applyNfcSnapshot(await api("nfc/status"));
} catch (error) {
applyNfcSnapshot({
revision: Date.now(),
reader: { online: false, state: "error", port: "auto", error: error.message },
card: null,
}, { announce: false });
}
}
function startNfcPolling() {
if (nfcPollTimer !== null) return;
void refreshNfcStatus();
nfcPollTimer = window.setInterval(() => {
if (document.visibilityState === "visible") void refreshNfcStatus();
}, NFC_POLL_INTERVAL_MS);
}
function stopNfcPolling() {
if (nfcPollTimer !== null) window.clearInterval(nfcPollTimer);
nfcPollTimer = null;
}
function openNfcDialog() {
renderNfcDialog();
openDialog("extensionNfcDialog");
void refreshNfcStatus();
}
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
if (event.target.closest("[data-open-nfc-learning]")) openNfcDialog();
});
const dialog = $("#extensionNfcDialog");
dialog.addEventListener("click", (event) => {
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
if (event.target.closest("[data-nfc-create-and-learn]")) createToyFromNfc();
if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting();
if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc();
const relearn = event.target.closest("[data-nfc-relearn-capability]");
if (relearn) continueNfcLearning(relearn.dataset.nfcRelearnCapability);
const learn = event.target.closest("[data-nfc-learn-capability]");
if (learn) continueNfcLearning(learn.dataset.nfcLearnCapability);
if (event.target.closest("[data-nfc-continue-learning]")) continueNfcLearning();
const run = event.target.closest("[data-nfc-run-capability]");
if (run) void emitNfcCapability(run.dataset.nfcRunCapability);
});
$("#extensionContinueLearn").addEventListener("click", () => {
try {
learning.draft = collectLearningDraft();
showLearnPage(2);
} catch (error) {
showToast(error.message, "error");
}
});
$("#extensionStartLearn").addEventListener("click", startLearning);
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
$("#extensionTestEmit").addEventListener("click", testLearning);
$("#extensionConfirmLearn").addEventListener("click", () => {
confirmLearning();
void syncPeripheralCatalog(linkedDeviceForCard(nfcSnapshot.card?.uid));
});
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
if (learning.phase === "listening") {
event.preventDefault();
cancelLearning().then(() => closeDialog("extensionLearnDialog"));
}
});
$$('[data-close-extension-dialog="extensionLearnDialog"]').forEach((button) => {
button.addEventListener("click", async () => {
if (learning.phase === "listening") await cancelLearning();
closeDialog("extensionLearnDialog");
});
});
window.addEventListener("pagehide", stopNfcPolling);
window.addEventListener("pageshow", startNfcPolling);
}
function buildUnifiedExtensionSync() {
const mount = $("#vehicleExtensionSync");
if (!mount) return false;
@@ -2298,6 +2953,21 @@
const render = () => {
const health = snapshot?.extension_health || {};
const embeddedNfc = health.nfc;
if (nfcSnapshot.revision < 0 && embeddedNfc) {
nfcSnapshot = {
revision: Number(embeddedNfc.revision ?? -1),
reader: embeddedNfc.reader || {},
card: embeddedNfc.card || null,
legacyProfiles: Array.isArray(embeddedNfc.legacy_profiles)
? embeddedNfc.legacy_profiles
: [],
};
if (nfcSnapshot.legacyProfiles.length) importLegacyProfiles(nfcSnapshot.legacyProfiles);
if (Array.isArray(embeddedNfc.legacy_bridge_addresses)) {
void migrateLegacyProfiles(embeddedNfc.legacy_bridge_addresses);
}
}
const physicalConnected = health.transport !== "simulation"
&& Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
@@ -2311,13 +2981,24 @@
candidate.id || `${candidate.device_id}:${candidate.action}`
) === identity) === index;
});
const nfcReader = nfcSnapshot.reader || {};
const nfcCard = nfcSnapshot.card;
const sensedCardUid = normalizeCardUid(nfcCard?.uid);
const nfcDevice = linkedDeviceForCard(sensedCardUid);
const activeCatalog = sensedCardUid
? mergedCatalog.filter((item) => (
normalizeCardUid(item.card_uid) === sensedCardUid
&& item.status === "learned"
))
: [];
const grouped = new Map();
mergedCatalog.forEach((item) => {
activeCatalog.forEach((item) => {
const deviceId = item.device_id || "external-device";
if (!grouped.has(deviceId)) grouped.set(deviceId, {
id: deviceId,
name: item.device_name || item.type_label || "USB 扩展设备",
type: item.type_label || item.device_type || "扩展硬件",
cardUid: item.card_uid || null,
actions: [],
});
grouped.get(deviceId).actions.push(item.action || item.name || "已学习能力");
@@ -2325,60 +3006,102 @@
const devices = [...grouped.values()];
const lastAction = snapshot?.extension?.last_action;
const lastResult = snapshot?.extension?.result;
const nfcCapabilities = nfcDevice ? learnedCapabilities(nfcDevice.id) : [];
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
const statusLabel = physicalConnected
? "USB 外设已接入"
: devices.length
? "外设目录已同步"
const statusLabel = devices.length
? "已感应 NFC 外设"
: sensedCardUid
? "当前 NFC 暂无已学习能力"
: nfcReader.online
? "等待 NFC 外设靠近"
: "NFC 读卡器离线";
const gatewayLabel = physicalConnected
? "GSCU1B 已连接"
: health.transport === "simulation"
? "扩展仿真链路"
: listening
? "等待 USB 外设接入"
: "扩展链路检查中";
const statusNote = physicalConnected
? `${health.peer || "GSCU1B"} · 已同步 ${mergedCatalog.length} 项扩展能力`
: devices.length
? `车机已同步 ${devices.length} 个外设、${mergedCatalog.length} 项扩展能力`
: health.message || "插入外设后,车机将自动识别并同步到此处";
? "等待 GSCU1B 接入"
: "红外链路检查中";
const gatewayNote = physicalConnected
? `${health.peer || "GSCU1B"} · 红外学习与发射链路已就绪`
: health.message || "红外学习器接入后可学习并发射玩具动作";
const visibleLastAction = devices.length ? lastAction : null;
const nfcState = !nfcReader.online
? "offline"
: nfcDevice
? "recognized"
: nfcCard?.uid
? "new-card"
: "waiting";
const nfcTitle = nfcDevice
? `${nfcDevice.toyNumber ? `#${nfcDevice.toyNumber} · ` : ""}${nfcDevice.name}`
: nfcCard?.uid
? "检测到新的玩具 NFC"
: nfcReader.online
? "等待玩具靠近"
: "NFC 读卡器未连接";
const nfcNote = nfcDevice
? `${nfcCapabilities.length} 项已学习动作 · ${nfcCard.uid}`
: nfcCard?.uid
? `${nfcCard.uid} · 点击建立或绑定玩具档案`
: nfcReaderNote(nfcReader);
mount.dataset.hasDevices = String(devices.length > 0);
mount.innerHTML = `
<header class="v2-extension-sync-heading">
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3></div>
<span data-state="${physicalConnected || devices.length ? "online" : listening ? "listening" : "offline"}"><i></i>${statusLabel}</span>
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3><button class="v2-extension-nfc-launch" type="button" data-open-nfc-learning><i data-lucide="scan-line"></i><span>感应玩具 NFC</span></button></div>
<span data-state="${devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
<button type="button" data-panel-toggle="extensions" aria-label="${panelCollapsed ? "展开" : "收起"}外设与扩展能力"><i data-lucide="panel-right-close"></i><span data-panel-action-label>${panelCollapsed ? "展开" : "收起"}</span></button>
</header>
<section class="v2-nfc-inline" data-state="${nfcState}">
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
<div><small>靠近读卡器后</small><strong>${escapeHtml(nfcTitle)}</strong><p>${escapeHtml(nfcNote)}</p>${nfcCapabilities.length ? `<div>${nfcCapabilities.slice(0, 6).map((capability) => `<i>${escapeHtml(capability.name)}</i>`).join("")}</div>` : ""}</div>
<button type="button" data-open-nfc-learning>${nfcDevice ? "查看能力" : nfcCard?.uid ? "建立档案" : "开始感应"}</button>
</section>
<div class="v2-extension-gateway">
<span><i data-lucide="usb"></i></span>
<div><strong>${statusLabel}</strong><small>${escapeHtml(statusNote)}</small></div>
<div><strong>${gatewayLabel}</strong><small>${escapeHtml(gatewayNote)}</small></div>
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
</div>
<div class="v2-extension-device-list">
${devices.length ? devices.map((device) => `
<article class="v2-extension-synced-device">
<header><span><i data-lucide="cpu"></i></span><div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.type)}</small></div><b>${device.actions.length} 项能力</b></header>
<header><span><i data-lucide="${device.cardUid ? "badge-check" : "cpu"}"></i></span><div><strong>${escapeHtml(device.name)}</strong><small>${escapeHtml(device.type)}${device.cardUid ? " · 已绑定 NFC" : ""}</small></div><b>${device.actions.length} 项能力</b></header>
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
</article>`).join("") : `
<div class="v2-extension-empty">
<i data-lucide="cable"></i><div><strong>暂无已识别外设</strong><span>外设名称、类型和已学习能力将由车机同步显示</span></div>
<i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无可显示能力" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "仅显示已绑定并完成红外学习的动作" : "将已绑定且已学习的 NFC 外设靠近读卡器后显示能力"}</span></div>
</div>`}
</div>
<footer class="v2-extension-linkage" data-active="${String(Boolean(lastAction))}">
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(lastAction?.command || lastAction?.type || "等待外设与车端设备联动")}</strong></div>
<span>${escapeHtml(lastResult?.status || lastResult?.message || "STANDBY")}</span>
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
<i data-lucide="workflow"></i><div><small>最近联动</small><strong>${escapeHtml(visibleLastAction?.command || visibleLastAction?.type || "等待 NFC 外设感应")}</strong></div>
<span>${escapeHtml(visibleLastAction ? (lastResult?.status || lastResult?.message || "STANDBY") : "STANDBY")}</span>
</footer>`;
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
};
renderUnifiedExtensionView = render;
window.addEventListener("cognitive-agent:display-sync", (event) => {
snapshot = event.detail || null;
if (snapshot?.extension_health?.nfc) {
applyNfcSnapshot(snapshot.extension_health.nfc, { announce: false });
}
render();
});
buildDialogs();
buildUnifiedNfcDialog();
bindUnifiedNfcEvents(mount);
render();
renderNfcDialog();
startNfcPolling();
void syncPeripheralCatalog();
return true;
}
function init() {
if (buildUnifiedExtensionSync()) return;
if (buildUnifiedExtensionSync()) {
void migrateLegacyProfiles();
return;
}
if (!buildShell()) return;
initializeDesktopPet();
bindEvents();
@@ -2386,6 +3109,7 @@
setMode(activeMode);
if (document.body.dataset.module === "self-extension") refreshHealth();
startHealthPolling();
void migrateLegacyProfiles();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}