feat: integrate learned peripherals into mode creation

Expose safe NFC-bound infrared actions to semantic scene composition, require flashing-light actions for romantic and Mid-Autumn modes, and preserve mixed CAN/IR mode flows.

Fix NFC device reassignment and retry extension/toy MQTT publication when an active card's binding catalog changes.
This commit is contained in:
Miayui
2026-09-20 16:37:19 +08:00
parent 904f5f8a6c
commit bfff1b515c
25 changed files with 3073 additions and 102 deletions
+160 -25
View File
@@ -373,6 +373,15 @@
card: null,
legacyProfiles: [],
};
let extensionToySnapshot = {
revision: -1,
state: "stopped",
topic: "extension/toy",
burst_published_count: 0,
last_error: null,
};
let peripheralCatalogRevision = null;
let peripheralCatalogSyncQueue = Promise.resolve();
let petCardSnapshot = {
revision: -1,
cards: [],
@@ -1966,6 +1975,7 @@
async function hydratePeripheralCatalog() {
const payload = await api("peripherals");
peripheralCatalogRevision = payload.catalog_revision || peripheralCatalogRevision;
const catalog = Array.isArray(payload.catalog) ? payload.catalog : [];
if (!catalog.length) return 0;
const restoredDeviceIds = new Set();
@@ -3754,6 +3764,12 @@
${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>` : ""}`;
} else {
const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
const alternativeDeviceGroups = deviceGroups.filter(
(item) => item.deviceId !== linkedDevice.id
&& !normalizeCardUid(
state.devices.find((device) => device.id === item.deviceId)?.cardUid,
),
);
const capabilities = configuredCapabilities(linkedDevice.id);
const enabledCount = capabilities.filter((capability) => capability.enabled !== false).length;
const allEnabled = capabilities.length > 0 && enabledCount === capabilities.length;
@@ -3766,6 +3782,7 @@
${renderNfcPetBinding(linkedPet)}
<div class="v2-nfc-capability-heading"><div><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>`}
${alternativeDeviceGroups.length ? `<div class="v2-nfc-existing v2-nfc-rebind"><span>将当前 NFC 改绑到新设备 ID</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${alternativeDeviceGroups.map((item) => `<option value="${escapeHtml(item.deviceId)}">${escapeHtml(item.deviceId)} — ${escapeHtml(item.objectNames.join(" / ") || "无对象描述")} — ${item.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>改绑设备 ID</button></div><small>改绑成功后,旧设备 ID 的 NFC 授权会解除,新设备的全部有效命令会默认启用。</small></div>` : ""}
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
}
nfcDialogFingerprint = renderFingerprint;
@@ -3788,19 +3805,32 @@
document.body.append(dialog);
}
async function syncPeripheralCatalog(device = null) {
try {
await api("display-sync", {
async function syncPeripheralCatalog(device = null, { throwOnError = false } = {}) {
const operation = peripheralCatalogSyncQueue
.catch(() => null)
.then(() => api("display-sync", {
method: "POST",
body: JSON.stringify({ peripherals: peripheralCatalog(), device }),
});
body: JSON.stringify({
peripherals: peripheralCatalog(),
device,
catalog_revision: peripheralCatalogRevision,
}),
}));
peripheralCatalogSyncQueue = operation.catch(() => null);
try {
const result = await operation;
peripheralCatalogRevision = result.catalog_revision || peripheralCatalogRevision;
return result;
} catch (error) {
console.warn("外设能力同步失败", error);
if (throwOnError) throw error;
return null;
}
}
function bindCardToDevice(device, cardUid) {
async function bindCardToDevice(device, cardUid) {
if (!device || !cardUid) return;
const previousBindings = state.devices.map((item) => [item.id, item.cardUid || null]);
const normalizedUid = normalizeCardUid(cardUid);
state.devices.forEach((item) => {
if (item.id !== device.id && normalizeCardUid(item.cardUid) === normalizedUid) {
@@ -3812,62 +3842,167 @@
state.selectedDeviceByType[device.type] = device.id;
saveState();
renderAll();
void syncPeripheralCatalog(device);
try {
await syncPeripheralCatalog(device, { throwOnError: true });
} catch (error) {
previousBindings.forEach(([id, cardUidValue]) => {
const item = state.devices.find((candidate) => candidate.id === id);
if (item) item.cardUid = cardUidValue;
});
saveState();
renderAll();
throw error;
}
}
function bindCurrentNfcToExisting() {
async 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) => {
if (!cardUid) {
showToast("当前没有可用于关联的 NFC 卡片。", "error");
return;
}
if (!device) {
showToast("未找到所选设备 ID,请刷新红外槽位后重试。", "error");
return;
}
const capabilities = configuredCapabilities(device.id);
if (!capabilities.length) {
showToast(`设备 ID“${device.id}”没有可授权的有效红外命令。`, "error");
return;
}
const previousDevice = linkedDeviceForCard(cardUid);
const previousEnabled = capabilities.map((capability) => [capability.id, capability.enabled]);
capabilities.forEach((capability) => {
capability.enabled = true;
});
bindCardToDevice(device, cardUid);
try {
await bindCardToDevice(device, cardUid);
} catch (error) {
previousEnabled.forEach(([id, enabled]) => {
const capability = capabilities.find((item) => item.id === id);
if (capability) capability.enabled = enabled;
});
saveState();
renderAll();
renderNfcDialog();
showToast(`NFC 设备授权保存失败:${error.message}`, "error");
return;
}
renderNfcDialog();
showToast(`NFC 已关联设备 ID“${device.id}”,全部命令已默认选中。`, "success");
showToast(
previousDevice && previousDevice.id !== device.id
? `NFC 已从设备 ID“${previousDevice.id}”改绑到“${device.id}”,新设备命令已全部启用。`
: `NFC 已关联设备 ID“${device.id}”,全部命令已默认选中。`,
"success",
);
}
function unbindCurrentNfc() {
async function unbindCurrentNfc() {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
const previousUid = device.cardUid;
device.cardUid = null;
saveState();
renderAll();
void syncPeripheralCatalog(device);
try {
await syncPeripheralCatalog(device, { throwOnError: true });
} catch (error) {
device.cardUid = previousUid;
saveState();
renderAll();
renderNfcDialog();
showToast(`解除 NFC 绑定失败:${error.message}`, "error");
return;
}
renderNfcDialog();
showToast(`已解除设备 ID“${device.id}”的 NFC 绑定。`);
}
function updateNfcCapabilityAuthorization(capabilityId, enabled) {
async function updateNfcCapabilityAuthorization(capabilityId, enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
const capability = configuredCapabilities(device?.id).find(
(item) => item.id === capabilityId,
);
if (!device || !capability) return;
const previousEnabled = capability.enabled;
capability.enabled = enabled;
saveState();
renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
try {
await syncPeripheralCatalog(device, { throwOnError: true });
} catch (error) {
capability.enabled = previousEnabled;
saveState();
renderAll();
renderNfcDialog();
showToast(`NFC 命令授权保存失败:${error.message}`, "error");
}
}
function updateAllNfcCapabilityAuthorizations(enabled) {
async function updateAllNfcCapabilityAuthorizations(enabled) {
const device = linkedDeviceForCard(nfcSnapshot.card?.uid);
if (!device) return;
configuredCapabilities(device.id).forEach((capability) => {
const capabilities = configuredCapabilities(device.id);
const previousEnabled = capabilities.map((capability) => [capability.id, capability.enabled]);
capabilities.forEach((capability) => {
capability.enabled = enabled;
});
saveState();
renderAll();
renderNfcDialog();
void syncPeripheralCatalog(device);
try {
await syncPeripheralCatalog(device, { throwOnError: true });
} catch (error) {
previousEnabled.forEach(([id, previous]) => {
const capability = capabilities.find((item) => item.id === id);
if (capability) capability.enabled = previous;
});
saveState();
renderAll();
renderNfcDialog();
showToast(`NFC 命令授权保存失败:${error.message}`, "error");
}
}
function applyExtensionToySnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return false;
const revision = Number(value.revision ?? -1);
if (revision === extensionToySnapshot.revision) return false;
extensionToySnapshot = {
revision,
state: String(value.state || "stopped"),
topic: String(value.topic || "extension/toy"),
burst_published_count: Number(value.burst_published_count || 0),
active_extension_names: Array.isArray(value.active_extension_names)
? value.active_extension_names.map((item) => String(item || "")).filter(Boolean)
: [],
last_delivery_status: value.last_delivery_status || null,
last_error: value.last_error || null,
};
if (announce && extensionToySnapshot.state === "burst_complete") {
showToast(
`已向 ${extensionToySnapshot.topic} 发送 ${extensionToySnapshot.burst_published_count} 帧设备能力消息。`,
"success",
);
} else if (announce && extensionToySnapshot.state === "burst_failed") {
showToast(
`${extensionToySnapshot.topic} 发送失败:${extensionToySnapshot.last_error || "MQTT 未连接"}`,
"error",
);
}
return true;
}
function applyNfcSnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return;
if (value.pet_cards) applyPetCardSnapshot(value.pet_cards);
if (value.extension_toy) {
applyExtensionToySnapshot(value.extension_toy, { announce });
}
const revision = Number(value.revision ?? -1);
if (revision === nfcSnapshot.revision) return;
if (Array.isArray(value.legacy_profiles) && value.legacy_profiles.length) {
@@ -3893,8 +4028,8 @@
if (announce) {
showToast(
pet
? `已识别宠物“${pet.pet_name}”和设备 ID“${device.id}”。`
: `已识别设备 ID“${device.id}”。`,
? `已识别宠物“${pet.pet_name}”和设备 ID“${device.id}”,正在准备 extension/toy 消息。`
: `已识别设备 ID“${device.id}”,正在准备 extension/toy 消息。`,
"success",
);
}
@@ -3958,8 +4093,8 @@
return;
}
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting();
if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc();
if (event.target.closest("[data-nfc-bind-existing]")) void bindCurrentNfcToExisting();
if (event.target.closest("[data-nfc-unbind]")) void unbindCurrentNfc();
if (event.target.closest("[data-nfc-bind-pet]")) void bindCurrentNfcToPet();
const unbindPet = event.target.closest("[data-nfc-unbind-pet]");
if (unbindPet) void unbindPetCard(unbindPet.dataset.nfcUnbindPet);
@@ -3969,14 +4104,14 @@
if (petSelect) selectedPetCardId = petSelect.value;
const capability = event.target.closest("[data-nfc-capability-enabled]");
if (capability) {
updateNfcCapabilityAuthorization(
void updateNfcCapabilityAuthorization(
capability.dataset.nfcCapabilityEnabled,
capability.checked,
);
return;
}
const selectAll = event.target.closest("[data-nfc-select-all]");
if (selectAll) updateAllNfcCapabilityAuthorizations(selectAll.checked);
if (selectAll) void updateAllNfcCapabilityAuthorizations(selectAll.checked);
});
$("#extensionContinueLearn").addEventListener("click", () => {
try {