添加NFC绑定红外设备方式,融合车端设备和扩展设备统一控制
This commit is contained in:
+394
-145
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user