添加NFC绑定红外设备方式,融合车端设备和扩展设备统一控制

This commit is contained in:
minghanchen
2026-09-01 11:04:59 +08:00
parent 6d75944c0f
commit ca26259e63
14 changed files with 3694 additions and 316 deletions
+394 -145
View File
@@ -476,14 +476,12 @@
saved.infraredCommands = Array.isArray(saved.infraredCommands)
? saved.infraredCommands.filter((item) => (
validSlot(item?.slot)
&& typeof item?.name === "string"
&& item.name.trim()
)).map((item) => ({
slot: Number(item.slot),
name: item.name.trim().slice(0, 48),
source: String(item.source || "tcp"),
learnedAt: item.learnedAt || null,
}))
&& (
String(item?.name || "").trim()
|| normalizedInfraredNames(item?.object_names ?? item?.objectNames).length
|| normalizedInfraredNames(item?.action_names ?? item?.actionNames).length
)
)).map(normalizedInfraredSlotRecord)
: [];
saved.selectedDeviceByType = saved.selectedDeviceByType || {};
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 };
@@ -724,7 +722,7 @@
<button class="v2-extension-primary" type="submit" id="extensionGscuTcpApply">应用并开始监听</button>
</form>
<section class="v2-gscu-actions">
<header><div><strong>TCP 动作调试</strong><small>与 gscu1b_control.py tcp 使用相同 JSON 报文</small></div><label><span>红外槽位</span><input id="extensionGscuSlot" type="number" min="1" max="248" value="1" /></label><label><span>指令名称</span><input id="extensionGscuCommandName" maxlength="48" placeholder="例如:客厅空调开启" /></label></header>
<header><div><strong>TCP 动作调试</strong><small>设备 ID 唯一标识控制对象;对象与动作可填写多个自然语言别名</small></div><label><span>红外槽位</span><input id="extensionGscuSlot" type="number" min="1" max="248" value="1" /></label><label><span>设备 ID</span><input id="extensionGscuDeviceId" maxlength="64" placeholder="toy-light-001" /></label><label><span>控制对象名称</span><input id="extensionGscuObjectNames" maxlength="240" placeholder="闪烁灯、闪光灯、闪灯玩具" /></label><label><span>执行动作</span><input id="extensionGscuActionNames" maxlength="240" placeholder="打开、开启、启动" /></label></header>
<div class="v2-gscu-action-grid">
<button type="button" data-gscu-action="info">查询设备</button>
<button type="button" data-gscu-action="learn">开始学习</button>
@@ -744,8 +742,8 @@
<label><span>恢复出厂确认</span><input id="extensionGscuDangerConfirm" autocomplete="off" placeholder="恢复出厂请输入 RESET_DEVICE" /></label>
<div><button class="danger" type="button" data-gscu-action="erase">擦除全部红外码</button><button class="danger" type="button" data-gscu-action="reset">恢复出厂设置</button></div>
</details>
<section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong><small>槽位与名称保存在本机数据库,并按槽位号排列</small></header><div id="extensionGscuNamedCommands"></div></section>
<pre id="extensionGscuTcpResult">等待操作</pre>
<section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong><small>按设备 ID 分组;对象名称用于自然语言识别和调试,动作请包含结果值,如“切换为蓝色”</small></header><div id="extensionGscuNamedCommands"></div></section>
<details class="v2-gscu-result" id="extensionGscuTcpResultDetails"><summary>JSON 调试结果</summary><pre id="extensionGscuTcpResult">等待操作</pre></details>
</section>
</dialog>
<div class="v2-extension-toast-stack" id="extensionToastStack" aria-live="polite"></div>`;
@@ -817,7 +815,7 @@
$("b", status).textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE";
if (refreshControls) {
renderGscuNamedCommands();
syncGscuCommandNameFromSlot();
syncGscuCommandNamesFromSlot();
}
}
@@ -826,24 +824,143 @@
return [...state.infraredCommands].sort((left, right) => Number(left.slot) - Number(right.slot));
}
function normalizedInfraredNames(value) {
const values = Array.isArray(value)
? value
: String(value || "").split(/[,,、;;|]+/);
return [...new Set(values
.map((item) => String(item || "").trim().slice(0, 48))
.filter(Boolean))].slice(0, 24);
}
function normalizedInfraredDeviceId(value) {
return String(value || "").trim().slice(0, 64);
}
function normalizedInfraredSlotRecord(item) {
const objectNames = normalizedInfraredNames(item.object_names ?? item.objectNames);
const actionNames = normalizedInfraredNames(item.action_names ?? item.actionNames);
const legacyName = String(item.name || "").trim().slice(0, 48);
return {
slot: Number(item.slot),
name: String(item.name || "").trim().slice(0, 48),
deviceId: normalizedInfraredDeviceId(item.device_id ?? item.deviceId),
name: legacyName || `${objectNames[0] || "未命名对象"} · ${actionNames[0] || "未命名动作"}`,
objectNames,
actionNames: actionNames.length || objectNames.length ? actionNames : normalizedInfraredNames(legacyName),
source: String(item.source || "tcp"),
learnedAt: item.learned_at || item.learnedAt || null,
updatedAt: item.updated_at || item.updatedAt || null,
};
}
function infraredDeviceGroups() {
const groups = new Map();
namedInfraredCommands().forEach((command) => {
const deviceId = normalizedInfraredDeviceId(command.deviceId);
const key = deviceId || "";
if (!groups.has(key)) {
groups.set(key, { deviceId, objectNames: [], commands: [] });
}
const group = groups.get(key);
group.commands.push(command);
group.objectNames = normalizedInfraredNames([
...group.objectNames,
...command.objectNames,
]);
});
return [...groups.values()].sort((left, right) => {
if (!left.deviceId) return 1;
if (!right.deviceId) return -1;
return left.deviceId.localeCompare(right.deviceId);
});
}
function reconcileDevicesAndCapabilitiesFromSlots() {
const configuredKeys = new Set();
infraredDeviceGroups().filter((group) => group.deviceId).forEach((group) => {
let device = state.devices.find((item) => item.id === group.deviceId);
if (!device) {
device = {
id: group.deviceId,
name: group.objectNames[0] || group.deviceId,
type: "other",
aliases: group.objectNames.length ? group.objectNames : [group.deviceId],
toyNumber: null,
cardUid: null,
createdAt: new Date().toISOString(),
source: "infrared_slot_catalog",
};
state.devices.push(device);
} else {
device.name = group.objectNames[0] || device.name || group.deviceId;
device.aliases = group.objectNames.length ? group.objectNames : [device.name];
}
group.commands.forEach((command) => {
const key = `${group.deviceId}\u0000${command.slot}`;
configuredKeys.add(key);
let capability = state.capabilities.find((item) => (
item.deviceId === group.deviceId && Number(item.slot) === Number(command.slot)
));
if (!capability) {
capability = {
id: `ir-slot:${group.deviceId}:${command.slot}`,
deviceId: group.deviceId,
key: `slot_${command.slot}`,
createdAt: new Date().toISOString(),
enabled: true,
};
state.capabilities.push(capability);
}
Object.assign(capability, {
deviceId: group.deviceId,
name: command.actionNames[0] || command.name,
aliases: command.actionNames.length ? command.actionNames : [command.name],
category: "infrared_slot",
risk: capability.risk || "low",
slot: Number(command.slot),
status: "learned",
verified: true,
enabled: capability.enabled !== false,
signalSource: "slot_catalog",
});
});
});
state.capabilities = state.capabilities.filter((capability) => {
if (!["slot_catalog", "backend_catalog"].includes(capability.signalSource)) return true;
return configuredKeys.has(`${capability.deviceId}\u0000${Number(capability.slot)}`);
});
}
function configuredCapabilities(deviceId = null) {
const configuredKeys = new Set(
namedInfraredCommands()
.filter((command) => command.deviceId)
.map((command) => `${command.deviceId}\u0000${Number(command.slot)}`),
);
return state.capabilities.filter((item) => (
(!deviceId || item.deviceId === deviceId)
&& item.status === "learned"
&& item.verified
&& validSlot(item.slot)
&& configuredKeys.has(`${item.deviceId}\u0000${Number(item.slot)}`)
));
}
function replaceInfraredSlotCatalog(items) {
state.infraredCommands = (Array.isArray(items) ? items : [])
.filter((item) => validSlot(item?.slot) && String(item?.name || "").trim())
.filter((item) => validSlot(item?.slot) && (
String(item?.name || "").trim()
|| normalizedInfraredNames(item?.object_names ?? item?.objectNames).length
|| normalizedInfraredNames(item?.action_names ?? item?.actionNames).length
))
.map(normalizedInfraredSlotRecord)
.sort((left, right) => left.slot - right.slot);
reconcileDevicesAndCapabilitiesFromSlots();
saveState();
renderGscuNamedCommands();
syncGscuCommandNameFromSlot();
syncGscuCommandNamesFromSlot();
if ($("#extensionNfcDialog")) renderNfcDialog();
renderUnifiedExtensionView?.();
}
async function refreshInfraredSlotCatalog({ migrateLegacy = true } = {}) {
@@ -857,7 +974,16 @@
if (missing.length) {
await Promise.all(missing.map((item) => api(`gscu/slots/${item.slot}`, {
method: "PUT",
body: JSON.stringify({ name: item.name, source: item.source || "browser-migration" }),
body: JSON.stringify(
item.deviceId && item.objectNames?.length && item.actionNames?.length
? {
device_id: item.deviceId,
object_names: item.objectNames,
action_names: item.actionNames,
source: item.source || "browser-migration",
}
: { name: item.name, source: item.source || "browser-migration" },
),
})));
result = await api("gscu/slots");
}
@@ -866,43 +992,74 @@
return namedInfraredCommands();
}
async function upsertNamedInfraredCommand(slot, name, source = "tcp") {
async function upsertNamedInfraredCommand(
slot,
deviceId,
objectNames,
actionNames,
source = "tcp",
) {
const normalizedSlot = Number(slot);
const normalizedName = String(name || "").trim().slice(0, 48);
const normalizedDeviceId = normalizedInfraredDeviceId(deviceId);
const normalizedObjects = normalizedInfraredNames(objectNames);
const normalizedActions = normalizedInfraredNames(actionNames);
if (!validSlot(normalizedSlot)) throw new Error("红外槽位范围是 1–248。");
if (!normalizedName) throw new Error("学习红外指令前需要填写指令名称。");
if (!normalizedDeviceId) throw new Error("请填写唯一的设备 ID。");
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalizedDeviceId)) {
throw new Error("设备 ID 必须以字母或数字开头,且只能包含字母、数字、点、下划线、冒号或连字符。");
}
if (!normalizedObjects.length) throw new Error("请至少填写一个控制对象名称。");
if (!normalizedActions.length) throw new Error("请至少填写一个执行动作。");
const result = await api(`gscu/slots/${normalizedSlot}`, {
method: "PUT",
body: JSON.stringify({ name: normalizedName, source }),
body: JSON.stringify({
device_id: normalizedDeviceId,
object_names: normalizedObjects,
action_names: normalizedActions,
source,
}),
});
const value = normalizedInfraredSlotRecord(result.slot);
const existing = state.infraredCommands.find((item) => item.slot === normalizedSlot);
if (existing) Object.assign(existing, value);
else state.infraredCommands.push(value);
reconcileDevicesAndCapabilitiesFromSlots();
const boundDevice = state.devices.find((item) => item.id === value.deviceId);
saveState();
renderGscuNamedCommands();
if (boundDevice) {
renderAll();
await syncPeripheralCatalog(boundDevice);
}
return value;
}
function renderGscuNamedCommands() {
const container = $("#extensionGscuNamedCommands");
if (!container) return;
const commands = namedInfraredCommands();
container.innerHTML = commands.length
? commands.map((command) => `<article data-gscu-command-slot="${command.slot}"><header><span>红外槽位</span><b>IR ${String(command.slot).padStart(3, "0")}</b></header><label><span>信号名称</span><input maxlength="48" value="${escapeHtml(command.name)}" aria-label="IR ${command.slot} 信号名称" /></label><footer><button type="button" data-gscu-named-emit="${command.slot}">发射</button><button type="button" data-gscu-named-save="${command.slot}">保存名称</button><button class="danger" type="button" data-gscu-named-remove="${command.slot}">删除本地记录</button></footer></article>`).join("")
: "<p>学习成功后将在这里显示编号与指令名称。</p>";
const groups = infraredDeviceGroups();
container.innerHTML = groups.length
? groups.map((group) => `<section class="v2-gscu-device-group" data-device-id="${escapeHtml(group.deviceId)}"><header><div><small>${group.deviceId ? "DEVICE ID" : "UNASSIGNED"}</small><strong>${escapeHtml(group.deviceId || "未分配设备 ID")}</strong><span>${escapeHtml(group.objectNames.join("、") || "尚无控制对象描述")}</span></div><b>${group.commands.length} 条命令</b></header><div class="v2-gscu-device-command-grid">${group.commands.map((command) => `<article data-gscu-command-slot="${command.slot}"><header><span>红外槽位</span><b>IR ${String(command.slot).padStart(3, "0")}</b></header><label><span>设备 ID</span><input data-gscu-device-id maxlength="64" value="${escapeHtml(command.deviceId)}" placeholder="唯一设备 ID" aria-label="IR ${command.slot} 设备 ID" /></label><label><span>控制对象名称 / 别名</span><input data-gscu-object-names maxlength="240" value="${escapeHtml(command.objectNames.join("、"))}" placeholder="用逗号分隔多个名称" aria-label="IR ${command.slot} 控制对象名称" /></label><label><span>执行动作 / 别名</span><input data-gscu-action-names maxlength="240" value="${escapeHtml(command.actionNames.join("、"))}" placeholder="颜色等动作需包含结果值" aria-label="IR ${command.slot} 执行动作" /></label><footer><button type="button" data-gscu-named-emit="${command.slot}">发射</button><button type="button" data-gscu-named-save="${command.slot}">保存设置</button><button class="danger" type="button" data-gscu-named-remove="${command.slot}">删除本地记录</button></footer></article>`).join("")}</div></section>`).join("")
: "<p>学习成功后将在这里按设备 ID 显示槽位、控制对象和执行动作。</p>";
}
function syncGscuCommandNameFromSlot() {
function syncGscuCommandNamesFromSlot() {
const slot = Number($("#extensionGscuSlot")?.value);
const command = namedInfraredCommands().find((item) => item.slot === slot);
if ($("#extensionGscuCommandName")) {
$("#extensionGscuCommandName").value = command?.name || "";
if ($("#extensionGscuDeviceId")) {
$("#extensionGscuDeviceId").value = command?.deviceId || "";
}
if ($("#extensionGscuObjectNames")) {
$("#extensionGscuObjectNames").value = command?.objectNames.join("、") || "";
}
if ($("#extensionGscuActionNames")) {
$("#extensionGscuActionNames").value = command?.actionNames.join("、") || "";
}
}
function openGscuTcpDialog(health = healthState || {}) {
renderGscuTcpDialog(health);
$("#extensionGscuTcpResultDetails")?.removeAttribute("open");
openDialog("extensionGscuTcpDialog");
void refreshInfraredSlotCatalog().catch((error) => {
showToast(`读取本地红外槽位数据库失败:${error.message}`, "error");
@@ -945,7 +1102,9 @@
async function runGscuTcpAction(button) {
const action = button.dataset.gscuAction;
const slot = Number($("#extensionGscuSlot").value);
const commandName = $("#extensionGscuCommandName").value.trim();
const deviceId = normalizedInfraredDeviceId($("#extensionGscuDeviceId").value);
const objectNames = normalizedInfraredNames($("#extensionGscuObjectNames").value);
const actionNames = normalizedInfraredNames($("#extensionGscuActionNames").value);
const payload = {};
if (["learn", "emit", "read", "write"].includes(action)) payload.slot = slot;
if (action === "write") payload.ir_data = $("#extensionGscuIrData").value.trim();
@@ -968,9 +1127,19 @@
payload.confirm = "ERASE_ALL";
}
if (action === "reset") payload.confirm = confirmation;
if (action === "learn" && !commandName) {
showToast("请输入该红外编号对应的指令名称。", "error");
$("#extensionGscuCommandName").focus();
if (action === "learn" && !deviceId) {
showToast("请输入该红外槽位对应的唯一设备 ID。", "error");
$("#extensionGscuDeviceId").focus();
return;
}
if (action === "learn" && !objectNames.length) {
showToast("请输入该红外槽位对应的控制对象名称。", "error");
$("#extensionGscuObjectNames").focus();
return;
}
if (action === "learn" && !actionNames.length) {
showToast("请输入该红外槽位对应的执行动作。", "error");
$("#extensionGscuActionNames").focus();
return;
}
button.disabled = true;
@@ -1006,7 +1175,7 @@
: "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。",
);
}
await upsertNamedInfraredCommand(slot, commandName, "tcp");
await upsertNamedInfraredCommand(slot, deviceId, objectNames, actionNames, "tcp");
result = {
...result,
learning_capture_confirmed: true,
@@ -1051,7 +1220,7 @@
if (!dialog || dialog.dataset.bound === "true") return;
dialog.dataset.bound = "true";
$("#extensionGscuTcpForm").addEventListener("submit", configureGscuTcp);
$("#extensionGscuSlot").addEventListener("change", syncGscuCommandNameFromSlot);
$("#extensionGscuSlot").addEventListener("change", syncGscuCommandNamesFromSlot);
dialog.addEventListener("click", (event) => {
if (event.target.closest('[data-close-extension-dialog="extensionGscuTcpDialog"]')) {
closeDialog("extensionGscuTcpDialog");
@@ -1068,7 +1237,9 @@
const command = namedInfraredCommands().find(
(item) => item.slot === Number(emit.dataset.gscuNamedEmit),
);
$("#extensionGscuCommandName").value = command?.name || "";
$("#extensionGscuDeviceId").value = command?.deviceId || "";
$("#extensionGscuObjectNames").value = command?.objectNames.join("、") || "";
$("#extensionGscuActionNames").value = command?.actionNames.join("、") || "";
const emitButton = $("[data-gscu-action=\"emit\"]", dialog);
if (emitButton) void runGscuTcpAction(emitButton);
return;
@@ -1079,10 +1250,12 @@
save.disabled = true;
void upsertNamedInfraredCommand(
Number(save.dataset.gscuNamedSave),
$("input", card).value,
$("[data-gscu-device-id]", card).value,
$("[data-gscu-object-names]", card).value,
$("[data-gscu-action-names]", card).value,
"tcp",
).then((value) => {
showToast(`IR ${value.slot} 已保存为“${value.name}”。`, "success");
showToast(`IR ${value.slot} 的控制对象与执行动作已保存。`, "success");
}).catch((error) => {
showToast(error.message, "error");
}).finally(() => { save.disabled = false; });
@@ -1095,9 +1268,15 @@
if (!window.confirm(`确认删除“${command?.name || `IR ${slot}`}”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。`)) return;
remove.disabled = true;
void api(`gscu/slots/${slot}`, { method: "DELETE" }).then((result) => {
const affectedDeviceId = command?.deviceId;
state.infraredCommands = namedInfraredCommands().filter((item) => item.slot !== slot);
reconcileDevicesAndCapabilitiesFromSlots();
saveState();
renderGscuNamedCommands();
renderAll();
void syncPeripheralCatalog(
state.devices.find((item) => item.id === affectedDeviceId) || null,
);
showToast(result.message || `IR ${slot} 的本地记录已删除。`, "success");
}).catch((error) => {
remove.disabled = false;
@@ -1660,7 +1839,8 @@
const capability = state.capabilities.find(
(item) => item.id === learning.draft.capabilityId,
);
if (!capability) return;
const device = state.devices.find((item) => item.id === capability?.deviceId);
if (!capability || !device) return;
const button = $("#extensionConfirmLearn");
button.disabled = true;
const relearning = Boolean(learning.draft.relearning);
@@ -1668,7 +1848,9 @@
try {
await upsertNamedInfraredCommand(
Number(learning.draft.slot),
capability.name,
device.id,
[device.name, ...(device.aliases || [])],
[capability.name, ...learning.draft.aliases],
"capability",
);
} catch (error) {
@@ -1715,28 +1897,123 @@
});
saveState();
renderAll();
void syncPeripheralCatalog();
showToast(`“${capability.name}”已恢复为待学习。`);
}
function peripheralCatalog() {
return learnedCapabilities().map((capability) => {
return configuredCapabilities().map((capability) => {
const device = state.devices.find((item) => item.id === capability.deviceId);
const type = TOY_TYPES[device?.type] || TOY_TYPES.other;
const slotRecord = namedInfraredCommands().find(
(item) => item.deviceId === capability.deviceId
&& Number(item.slot) === Number(capability.slot),
);
const objectNames = slotRecord?.objectNames?.length
? slotRecord.objectNames
: normalizedInfraredNames([device?.name, ...(device?.aliases || [])]);
const actionNames = slotRecord?.actionNames?.length
? slotRecord.actionNames
: normalizedInfraredNames([capability.name, ...(capability.aliases || [])]);
return {
id: capability.id,
status: "learned",
device_id: device?.id,
device_name: device?.name,
device_aliases: device?.aliases || [],
object_names: objectNames,
device_type: device?.type,
card_uid: device?.cardUid || null,
toy_number: device?.toyNumber || null,
type_label: type.label,
action: capability.name,
aliases: capability.aliases || [],
action_names: actionNames,
risk: capability.risk || "low",
slot: capability.slot,
enabled: capability.enabled !== false,
transport: "gscu_tcp",
};
}).filter((item) => item.device_id);
}).filter((item) => item.device_id && item.card_uid && validSlot(item.slot));
}
async function hydratePeripheralCatalog() {
const payload = await api("peripherals");
const catalog = Array.isArray(payload.catalog) ? payload.catalog : [];
if (!catalog.length) return 0;
const restoredDeviceIds = new Set();
catalog.forEach((item) => {
if (!item || typeof item !== "object" || !item.id || !validSlot(item.slot)) return;
const objectNames = normalizedInfraredNames(item.object_names);
const actionNames = normalizedInfraredNames(item.action_names);
let device = state.devices.find((candidate) => candidate.id === item.device_id);
if (!device) {
device = {
id: item.device_id,
name: objectNames[0] || item.device_name,
type: TOY_TYPES[item.device_type] ? item.device_type : "other",
aliases: objectNames.length
? objectNames
: Array.isArray(item.device_aliases)
? item.device_aliases
: [item.device_name],
toyNumber: item.toy_number || nextToyNumber(),
cardUid: normalizeCardUid(item.card_uid),
createdAt: new Date().toISOString(),
};
state.devices.push(device);
restoredDeviceIds.add(device.id);
} else {
device.name = objectNames[0] || item.device_name || device.name;
device.aliases = objectNames.length
? objectNames
: Array.isArray(item.device_aliases)
? item.device_aliases
: device.aliases || [device.name];
device.cardUid = normalizeCardUid(item.card_uid) || device.cardUid;
}
let capability = state.capabilities.find((candidate) => candidate.id === item.id);
if (!capability) {
capability = {
id: item.id,
deviceId: device.id,
key: "restored",
name: actionNames[0] || item.action,
aliases: actionNames.length
? actionNames
: Array.isArray(item.aliases) ? item.aliases : [item.action],
category: "restored",
risk: item.risk || "low",
visual: null,
createdAt: new Date().toISOString(),
};
state.capabilities.push(capability);
}
Object.assign(capability, {
deviceId: device.id,
name: actionNames[0] || item.action,
aliases: actionNames.length
? actionNames
: Array.isArray(item.aliases) ? item.aliases : capability.aliases || [],
risk: item.risk || "low",
slot: Number(item.slot),
status: "learned",
verified: true,
enabled: item.enabled !== false,
signalSource: "backend_catalog",
});
});
restoredDeviceIds.forEach((deviceId) => {
const device = state.devices.find((item) => item.id === deviceId);
if (!device) return;
const knownNames = new Set(deviceCapabilities(deviceId).map((item) => item.name));
seedPresets(device).forEach((capability) => {
if (!knownNames.has(capability.name)) state.capabilities.push(capability);
});
});
saveState();
renderAll();
return catalog.length;
}
function vehicleCatalog() {
@@ -3046,6 +3323,7 @@
const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card;
const linkedDevice = linkedDeviceForCard(card?.uid);
const deviceGroups = infraredDeviceGroups().filter((group) => group.deviceId);
status.textContent = nfcReaderNote(reader);
badge.textContent = reader.online ? "READER READY" : "READER OFFLINE";
badge.dataset.state = reader.online ? "online" : "offline";
@@ -3055,37 +3333,25 @@
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>`;
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 NFC</small><strong>请将 NFC 卡靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到一个已配置的设备 ID。</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>` : ""}`;
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到未绑定 NFC</small><strong>${escapeHtml(card.uid)}</strong><p>请选择设备 ID;控制对象名称仅作为调试辅助说明。</p></div></div>
${deviceGroups.length ? `<div class="v2-nfc-existing"><span>绑定对象(设备 ID)</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${deviceGroups.map((group) => `<option value="${escapeHtml(group.deviceId)}">${escapeHtml(group.deviceId)} — ${escapeHtml(group.objectNames.join(" / ") || "无对象描述")} — ${group.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>关联设备 ID</button></div><small>关联后,该设备的全部命令默认允许执行,可再逐项取消。</small></div>` : `<div class="v2-nfc-no-devices"><i data-lucide="info"></i><div><strong>暂无可绑定的设备 ID</strong><span>请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。</span></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");
const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
const capabilities = configuredCapabilities(linkedDevice.id);
const enabledCount = capabilities.filter((capability) => capability.enabled !== false).length;
const allEnabled = capabilities.length > 0 && enabledCount === capabilities.length;
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>
<span data-extension-type="other"><i data-lucide="cpu"></i></span>
<div><small>NFC UID · ${escapeHtml(card.uid)}</small><strong>${escapeHtml(linkedDevice.id)}</strong><p>控制对象:${escapeHtml(group?.objectNames.join("、") || linkedDevice.name || "无辅助描述")} · ${enabledCount}/${capabilities.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>`;
<div class="v2-nfc-capability-heading"><div><small>COMMAND AUTHORIZATION</small><strong>允许此 NFC 执行的命令</strong></div><span>${enabledCount}/${capabilities.length} 已选择</span></div>
${capabilities.length ? `<label class="v2-nfc-select-all"><input type="checkbox" data-nfc-select-all ${allEnabled ? "checked" : ""} /><span><strong>全选</strong><small>统一允许或禁止当前设备 ID 下的全部命令</small></span></label><div class="v2-nfc-command-list">${capabilities.map((capability) => `<label class="v2-nfc-command-option"><input type="checkbox" data-nfc-capability-enabled="${escapeHtml(capability.id)}" ${capability.enabled !== false ? "checked" : ""} /><span><strong>${escapeHtml(capability.name)}</strong><small>IR ${String(capability.slot).padStart(3, "0")} · ${escapeHtml((capability.aliases || []).join("、"))}</small></span></label>`).join("")}</div>` : `<div class="v2-nfc-no-devices"><i data-lucide="triangle-alert"></i><div><strong>该设备 ID 暂无有效命令</strong><span>槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。</span></div></div>`}
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
}
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
@@ -3095,12 +3361,12 @@
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>
dialog.innerHTML = `<header><div><span class="section-kicker">NFC · DEVICE AUTHORIZATION</span><h2>NFC 设备授权</h2><p>将 NFC UID 绑定到设备 ID,并限制该卡片可执行的红外命令。</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>
<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>`;
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>NFC UID、设备 ID 与命令授权同步到本机白名单;红外波形仍保存在 GSCU1B 物理槽位。</span></footer>`;
document.body.append(dialog);
}
@@ -3131,43 +3397,18 @@
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;
reconcileDevicesAndCapabilitiesFromSlots();
const device = state.devices.find((item) => item.id === deviceId);
const cardUid = nfcSnapshot.card?.uid;
if (!device || !cardUid) return;
configuredCapabilities(device.id).forEach((capability) => {
capability.enabled = true;
});
bindCardToDevice(device, cardUid);
showToast(`NFC 已绑定到“${device.name}”。`, "success");
renderNfcDialog();
showToast(`NFC 已关联设备 ID“${device.id}”,全部命令已默认选中。`, "success");
}
function unbindCurrentNfc() {
@@ -3177,36 +3418,33 @@
saveState();
renderAll();
void syncPeripheralCatalog(device);
showToast(`已解除“${device.name}”的 NFC 绑定。`);
renderNfcDialog();
showToast(`已解除设备 ID“${device.id}”的 NFC 绑定。`);
}
function continueNfcLearning(capabilityId = null) {
function updateNfcCapabilityAuthorization(capabilityId, enabled) {
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;
const capability = configuredCapabilities(device?.id).find(
(item) => item.id === capabilityId,
);
if (!device || !capability) return;
capability.enabled = enabled;
saveState();
openLearn(capability.id);
renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
}
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 updateAllNfcCapabilityAuthorizations(enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
configuredCapabilities(device.id).forEach((capability) => {
capability.enabled = enabled;
});
saveState();
renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
}
function applyNfcSnapshot(value, { announce = true } = {}) {
@@ -3232,10 +3470,10 @@
if (device) {
state.selectedDeviceByType[device.type] = device.id;
saveState();
if (announce) showToast(`已识别玩具“${device.name}”。`, "success");
if (announce) showToast(`已识别设备 ID“${device.id}”。`, "success");
void syncPeripheralCatalog(device);
} else if (announce) {
showToast("检测到新的玩具 NFC,请建立或绑定玩具档案。", "success");
showToast("检测到未绑定 NFC,请选择一个设备 ID。", "success");
}
}
renderNfcDialog();
@@ -3275,23 +3513,27 @@
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
if (event.target.closest("[data-open-nfc-learning]")) openNfcDialog();
if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
});
bindGscuTcpDialogEvents();
const dialog = $("#extensionNfcDialog");
dialog.addEventListener("click", (event) => {
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
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);
});
dialog.addEventListener("change", (event) => {
const capability = event.target.closest("[data-nfc-capability-enabled]");
if (capability) {
updateNfcCapabilityAuthorization(
capability.dataset.nfcCapabilityEnabled,
capability.checked,
);
return;
}
const selectAll = event.target.closest("[data-nfc-select-all]");
if (selectAll) updateAllNfcCapabilityAuthorizations(selectAll.checked);
});
$("#extensionContinueLearn").addEventListener("click", () => {
try {
@@ -3370,6 +3612,7 @@
? mergedCatalog.filter((item) => (
normalizeCardUid(item.card_uid) === sensedCardUid
&& item.status === "learned"
&& item.enabled !== false
))
: [];
const grouped = new Map();
@@ -3387,12 +3630,14 @@
const devices = [...grouped.values()];
const lastAction = snapshot?.extension?.last_action;
const lastResult = snapshot?.extension?.result;
const nfcCapabilities = nfcDevice ? learnedCapabilities(nfcDevice.id) : [];
const nfcCapabilities = nfcDevice
? configuredCapabilities(nfcDevice.id).filter((item) => item.enabled !== false)
: [];
const panelCollapsed = mount.closest("#v2ExtensionPanel")?.classList.contains("is-collapsed") === true;
const statusLabel = devices.length
? "已感应 NFC 外设"
: sensedCardUid
? "当前 NFC 暂无已学习能力"
? "当前 NFC 暂无已授权命令"
: nfcReader.online
? "等待 NFC 外设靠近"
: "NFC 读卡器离线";
@@ -3405,7 +3650,7 @@
: "红外链路检查中";
const gatewayNote = physicalConnected
? `${health.peer || "GSCU1B"} · 红外学习与发射链路已就绪`
: health.message || "红外学习器接入后可学习并发射玩具动作";
: health.message || "红外学习器接入后可学习并发射设备动作";
const visibleLastAction = devices.length ? lastAction : null;
const nfcState = !nfcReader.online
? "offline"
@@ -3415,28 +3660,28 @@
? "new-card"
: "waiting";
const nfcTitle = nfcDevice
? `${nfcDevice.toyNumber ? `#${nfcDevice.toyNumber} · ` : ""}${nfcDevice.name}`
? nfcDevice.id
: nfcCard?.uid
? "检测到新的玩具 NFC"
? "检测到未绑定 NFC"
: nfcReader.online
? "等待玩具靠近"
? "等待 NFC 卡靠近"
: "NFC 读卡器未连接";
const nfcNote = nfcDevice
? `${nfcCapabilities.length} 项已学习动作 · ${nfcCard.uid}`
? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
: nfcCard?.uid
? `${nfcCard.uid} · 点击建立或绑定玩具档案`
? `${nfcCard.uid} · 点击关联设备 ID`
: 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><button class="v2-extension-nfc-launch" type="button" data-open-nfc-learning><i data-lucide="scan-line"></i><span>感应玩具 NFC</span></button></div>
<div><small>USB EXTENSION · LIVE</small><h3>外设与扩展能力</h3><button class="v2-extension-nfc-launch" type="button" data-open-nfc-binding><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>
<button type="button" data-open-nfc-binding>${nfcDevice ? "查看授权" : nfcCard?.uid ? "关联设备" : "开始感应"}</button>
</section>
<div class="v2-extension-gateway">
<span><i data-lucide="usb"></i></span>
@@ -3451,7 +3696,7 @@
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
</article>`).join("") : `
<div class="v2-extension-empty">
<i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无可显示能力" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "仅显示已绑定并完成红外学习的动作" : "将已绑定且已学习的 NFC 外设靠近读卡器后显示能力"}</span></div>
<i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}</span></div>
</div>`}
</div>
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
@@ -3478,7 +3723,11 @@
render();
renderNfcDialog();
startNfcPolling();
void syncPeripheralCatalog();
void hydratePeripheralCatalog()
.catch((error) => {
console.warn("恢复后端红外能力目录失败,将保留浏览器本地目录。", error);
})
.finally(() => syncPeripheralCatalog());
return true;
}