+ {t('spoolman.unlinkConfirmMessage')} +
+AMS {amsId} T{trayId} · Printer #{printerId} @@ -115,28 +110,27 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a {t('inventory.noSpoolsMatch')}
) : ( - filteredSpools.map((spool: InventorySpool) => ( + filteredSpools.map((spool: UnlinkedSpool) => ( )) diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index bed8172a7..2f183a67a 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2776,10 +2776,14 @@ export default { linkToSpoolman: 'Mit Spoolman verknüpfen', openInSpoolman: 'In Spoolman öffnen', unlinkSpool: 'Spule trennen', + unlinkConfirmTitle: 'Spule entkoppeln?', + unlinkConfirmMessage: 'Dadurch wird die Spule von Spoolman getrennt. Die Spulendaten in Spoolman bleiben unverändert.', selectSpool: 'Spule auswählen', noUnlinkedSpools: 'Keine nicht verknüpften Spulen verfügbar', linkSuccess: 'Spule erfolgreich mit Spoolman verknüpft', linkFailed: 'Verknüpfung mit Spoolman fehlgeschlagen', + unlinkSuccess: 'Spule erfolgreich von Spoolman getrennt', + unlinkFailed: 'Trennen der Spule von Spoolman fehlgeschlagen', spoolId: 'Spulen-ID', fillSourceLabel: '(Spoolman)', weight: 'Gewicht', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e5a651d66..b6115b1d8 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2776,10 +2776,14 @@ export default { linkToSpoolman: 'Link to Spoolman', openInSpoolman: 'Open in Spoolman', unlinkSpool: 'Unlink Spool', + unlinkConfirmTitle: 'Unlink Spool?', + unlinkConfirmMessage: 'This will disconnect the spool from Spoolman. The spool data in Spoolman will remain unchanged.', selectSpool: 'Select Spool', noUnlinkedSpools: 'No unlinked spools available', linkSuccess: 'Spool linked to Spoolman successfully', linkFailed: 'Failed to link spool', + unlinkSuccess: 'Spool unlinked from Spoolman successfully', + unlinkFailed: 'Failed to unlink spool', spoolId: 'Spool ID', fillSourceLabel: '(Spoolman)', weight: 'Weight', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index e62339836..5bf504bb5 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2763,10 +2763,14 @@ export default { linkToSpoolman: 'Lier à Spoolman', openInSpoolman: 'Ouvrir Spoolman', unlinkSpool: 'Délier bobine', + unlinkConfirmTitle: 'Dissocier la bobine?', + unlinkConfirmMessage: 'Cette opération déconnectera la bobine de Spoolman. Les données de la bobine dans Spoolman resteront inchangées.', selectSpool: 'Choisir bobine', noUnlinkedSpools: 'Pas de bobine libre', linkSuccess: 'Lien réussi', linkFailed: 'Échec lien', + unlinkSuccess: 'Bobine dissociée avec succès', + unlinkFailed: 'Échec de la dissociation de la bobine', spoolId: 'ID Bobine', fillSourceLabel: '(Spoolman)', weight: 'Poids', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 8d3eec316..4e7a7acd6 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2762,10 +2762,14 @@ export default { linkToSpoolman: 'Collega a Spoolman', openInSpoolman: 'Apri in Spoolman', unlinkSpool: 'Scollega bobina', + unlinkConfirmTitle: 'Scollegare bobina?', + unlinkConfirmMessage: 'Questo disconnetterà lo spool da Spoolman. I dati dello spool in Spoolman rimarranno invariati.', selectSpool: 'Seleziona bobina', noUnlinkedSpools: 'Nessuna bobina scollegata disponibile', linkSuccess: 'Bobina collegata a Spoolman con successo', linkFailed: 'Collegamento bobina fallito', + unlinkSuccess: 'Bobina scollegata da Spoolman con successo', + unlinkFailed: 'Impossibile scollegare la bobina', spoolId: 'ID bobina', fillSourceLabel: '(Spoolman)', weight: 'Peso', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 76e13af65..c41bb583e 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2776,10 +2776,14 @@ export default { linkToSpoolman: 'Spoolmanに連携', openInSpoolman: 'Spoolmanで開く', unlinkSpool: 'スプールのリンクを解除', + unlinkConfirmTitle: 'スプールのリンクを解除しますか?', + unlinkConfirmMessage: 'これにより、スプールがSpoolmanから切断されます。Spoolman内のスプールデータは変更されません。', selectSpool: 'スプールを選択', noUnlinkedSpools: 'Spoolmanに未連携のスプールが見つかりません。', linkSuccess: 'スプールをSpoolmanにリンクしました', linkFailed: 'スプールのリンクに失敗しました', + unlinkSuccess: 'スプールをSpoolmanから解除しました', + unlinkFailed: 'スプールのリンク解除に失敗しました', spoolId: 'スプールID', fillSourceLabel: '(Spoolman)', weight: '重量', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index ee1fff864..bf89e865c 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2762,10 +2762,14 @@ export default { linkToSpoolman: 'Vincular ao Spoolman', openInSpoolman: 'Abrir no Spoolman', unlinkSpool: 'Desvincular Carretel', + unlinkConfirmTitle: 'Desvincular carretel?', + unlinkConfirmMessage: 'Isso desconectará o carretel do Spoolman. Os dados do carretel no Spoolman permanecerão inalterados.', selectSpool: 'Selecionar Carretel', noUnlinkedSpools: 'Nenhum carretel desvinculado disponível', linkSuccess: 'Carretel vinculado ao Spoolman com sucesso', linkFailed: 'Falha ao vincular carretel', + unlinkSuccess: 'Carretel desvinculado do Spoolman com sucesso', + unlinkFailed: 'Falha ao desvincular carretel', spoolId: 'Carretel ID (Spool ID)', fillSourceLabel: '(Spoolman)', weight: 'Peso', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 8688e2247..65520feda 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2762,10 +2762,14 @@ export default { linkToSpoolman: '链接到 Spoolman', openInSpoolman: '在 Spoolman 中打开', unlinkSpool: '取消链接耗材', + unlinkConfirmTitle: '解开线轴?', + unlinkConfirmMessage: '这将断开卷轴与 Spoolman 的连接。Spoolman 中的卷轴数据将保持不变。', selectSpool: '选择耗材', noUnlinkedSpools: '无未链接的耗材', linkSuccess: '耗材已成功链接到 Spoolman', linkFailed: '链接耗材失败', + unlinkSuccess: '已成功从 Spoolman 取消链接耗材', + unlinkFailed: '取消链接耗材失败', spoolId: '耗材 ID', fillSourceLabel: '(Spoolman)', weight: '重量', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 26784f783..f39a3e6ba 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1039,6 +1039,27 @@ function isBambuLabSpool(tray: { return false; } +function toFixedHex(value: number, width: number): string { + const safe = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; + return safe.toString(16).toUpperCase().padStart(width, '0').slice(-width); +} + +// 32-bit FNV-1a hash -> 8-char hex (stable for alphanumeric serials) +function hashSerialToHex32(serial: string): string { + const input = (serial || '').trim().toUpperCase(); + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).toUpperCase().padStart(8, '0'); +} + +function getFallbackSpoolTag(printerSerial: string, amsId: number, trayId: number): string { + // 16-char stable hex tag for slots without RFID identifiers + return `${hashSerialToHex32(printerSerial)}${toFixedHex(amsId, 4)}${toFixedHex(trayId, 4)}`; +} + function CoverImage({ url, printName }: { url: string | null; printName?: string }) { const { t } = useTranslation(); const [loaded, setLoaded] = useState(false); @@ -1492,7 +1513,6 @@ function PrinterCard({ cardSize = 2, amsThresholds, spoolmanEnabled = false, - hasUnlinkedSpools = false, linkedSpools, spoolmanUrl, onGetAssignment, @@ -1832,6 +1852,18 @@ function PrinterCard({ }, }); + const unlinkSpoolMutation = useMutation({ + mutationFn: (spoolId: number) => api.unlinkSpool(spoolId), + onSuccess: (result) => { + showToast(t('spoolman.unlinkSuccess') || result?.message, 'success'); + queryClient.invalidateQueries({ queryKey: ['linked-spools'] }); + queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] }); + }, + onError: (error: Error) => { + showToast(error.message || t('spoolman.unlinkFailed'), 'error'); + }, + }); + // Smart plug control mutations const powerControlMutation = useMutation({ mutationFn: (action: 'on' | 'off') => @@ -3034,7 +3066,7 @@ function PrinterCard({ const slotPreset = slotPresets?.[globalTrayId]; // Fill level fallback chain: Spoolman → Inventory → AMS remain - const trayTag = tray?.tray_uuid?.toUpperCase(); + const trayTag = (tray?.tag_uid || tray?.tray_uuid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx))?.toUpperCase(); const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined; const spoolmanFill = getSpoolmanFillLevel(linkedSpool); const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx); @@ -3153,18 +3185,21 @@ function PrinterCard({ data={filamentData} spoolman={{ enabled: spoolmanEnabled, - hasUnlinkedSpools, - linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined, + linkedSpoolId: trayTag + ? linkedSpools?.[trayTag]?.id + : undefined, spoolmanUrl, - onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => { + onLinkSpool: spoolmanEnabled ? () => { + const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx)).toUpperCase(); setLinkSpoolModal({ - tagUid: filamentData.tagUid || '', - trayUuid: uuid, + tagUid: filamentData.tagUid || linkTag, + trayUuid: filamentData.trayUuid || '', printerId: printer.id, amsId: ams.id, trayId: slotIdx, }); } : undefined, + onUnlinkSpool: linkedSpool?.id ? () => unlinkSpoolMutation.mutate(linkedSpool.id) : undefined, }} inventory={spoolmanEnabled ? undefined : (() => { const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx); @@ -3252,13 +3287,13 @@ function PrinterCard({ const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null; // Get saved slot preset mapping (for user-configured slots) const slotPreset = slotPresets?.[globalTrayId]; + const htSlotId = tray?.id ?? 0; // Fill level fallback chain: Spoolman → Inventory → AMS remain - const htTrayTag = tray?.tray_uuid?.toUpperCase(); + const htTrayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId))?.toUpperCase(); const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined; const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool); - const htTraySlotId = tray?.id ?? 0; - const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htTraySlotId); + const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htSlotId); const htInventoryFill = (() => { const sp = htInventoryAssignment?.spool; if (sp && sp.label_weight > 0 && sp.weight_used != null) { @@ -3285,7 +3320,6 @@ function PrinterCard({ fillSource: htFillSource, } : null; - const htSlotId = tray?.id ?? 0; // Check if this specific slot is being refreshed const isHtRefreshing = refreshingSlot?.amsId === ams.id && refreshingSlot?.slotId === htSlotId; @@ -3397,18 +3431,21 @@ function PrinterCard({ data={filamentData} spoolman={{ enabled: spoolmanEnabled, - hasUnlinkedSpools, - linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()]?.id : undefined, + linkedSpoolId: htTrayTag + ? linkedSpools?.[htTrayTag]?.id + : undefined, spoolmanUrl, - onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => { + onLinkSpool: spoolmanEnabled ? () => { + const linkTag = (filamentData.trayUuid || filamentData.tagUid || getFallbackSpoolTag(printer.serial_number, ams.id, htSlotId)).toUpperCase(); setLinkSpoolModal({ - tagUid: filamentData.tagUid || '', - trayUuid: uuid, + tagUid: filamentData.tagUid || linkTag, + trayUuid: filamentData.trayUuid || '', printerId: printer.id, amsId: ams.id, trayId: htSlotId, }); } : undefined, + onUnlinkSpool: htLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(htLinkedSpool.id) : undefined, }} inventory={spoolmanEnabled ? undefined : (() => { const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId); @@ -3525,7 +3562,7 @@ function PrinterCard({ const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null; const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId]; - const extTrayTag = extTray.tray_uuid?.toUpperCase(); + const extTrayTag = (extTray.tray_uuid || extTray.tag_uid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId))?.toUpperCase(); const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined; const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool); const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId); @@ -3590,18 +3627,21 @@ function PrinterCard({ data={extFilamentData} spoolman={{ enabled: spoolmanEnabled, - hasUnlinkedSpools, - linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined, + linkedSpoolId: extTrayTag + ? linkedSpools?.[extTrayTag]?.id + : undefined, spoolmanUrl, - onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => { + onLinkSpool: spoolmanEnabled ? () => { + const linkTag = (extFilamentData.trayUuid || extFilamentData.tagUid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId)).toUpperCase(); setLinkSpoolModal({ - tagUid: extFilamentData.tagUid || '', - trayUuid: uuid, + tagUid: extFilamentData.tagUid || linkTag, + trayUuid: extFilamentData.trayUuid || '', printerId: printer.id, amsId: 255, trayId: slotTrayId, }); } : undefined, + onUnlinkSpool: extLinkedSpool?.id ? () => unlinkSpoolMutation.mutate(extLinkedSpool.id) : undefined, }} inventory={spoolmanEnabled ? undefined : (() => { const assignment = onGetAssignment?.(printer.id, 255, slotTrayId);