Feature AMS Info Card (#570)

* AMS Labels addition to PrintersPage

* Added database reinitialization for schema migrations on database restore

* Bug fixes and AMS Label persistence updates

* Update database.py to resolve PR conflicts

* PR Comment Resolution

* Resolve conflicts in database.py for PR#570

* Updates to address PR#570 additional comments

* Optimize visibility handling for popup component

* Improve serial key handling in printers.py

Refactor serial key assignment to handle empty AMS serial gracefully.

* Implement error handling in serial number mapping

Add error handling for serial number mapping.

* Add migration to drop old ams_labels table

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
This commit is contained in:
Thomas Rambach
2026-03-05 08:36:39 +01:00
committed by GitHub
co-authored by MartinNYHC
parent 5ee2ef8dbf
commit 7ba67012f6
20 changed files with 786 additions and 36 deletions
+20
View File
@@ -134,6 +134,8 @@ export interface AMSUnit {
temp: number | null;
is_ams_ht: boolean; // True for AMS-HT (single spool), False for regular AMS (4 spools)
tray: AMSTray[];
serial_number: string; // AMS unit serial number (from MQTT sn field)
sw_ver: string; // AMS firmware version (from get_version info.module ams/* entry)
}
export interface NozzleInfo {
@@ -1878,6 +1880,7 @@ export interface SpoolAssignment {
spool?: InventorySpool | null;
configured: boolean;
created_at: string;
ams_label?: string | null; // User-defined friendly name for the AMS unit
}
// Update types
@@ -3345,6 +3348,23 @@ export const api = {
request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
method: 'DELETE',
}),
// AMS Labels (user-defined friendly names)
getAmsLabels: (printerId: number) =>
request<Record<number, string>>(`/printers/${printerId}/ams-labels`),
saveAmsLabel: (printerId: number, amsId: number, label: string, amsSerial = '') =>
request<{ ams_id: number; label: string }>(
`/printers/${printerId}/ams-labels/${amsId}`,
{
method: 'PUT',
body: JSON.stringify({ label, ams_serial: amsSerial }),
}
),
deleteAmsLabel: (printerId: number, amsId: number, amsSerial = '') =>
request<{ success: boolean }>(`/printers/${printerId}/ams-labels/${amsId}?ams_serial=${encodeURIComponent(amsSerial)}`, {
method: 'DELETE',
}),
configureAmsSlot: (
printerId: number,
amsId: number,
@@ -0,0 +1,47 @@
/**
* FilamentSlotCircle renders a small color circle with the 1-based slot
* number centered inside, matching the style used on AMS cards in PrintersPage.
*
* Props:
* trayColor - 6-char hex color string WITHOUT leading '#' (e.g. "FF0000").
* Pass undefined / empty string when the slot is empty.
* trayType - Filament material string (e.g. "PLA"). Used to decide the
* fallback background when there is no color but a type is known.
* isEmpty - Whether the slot contains no filament.
* slotNumber - 1-based slot number to display inside the circle.
*/
interface FilamentSlotCircleProps {
trayColor?: string | null;
trayType?: string | null;
isEmpty: boolean;
slotNumber: number;
}
function isLightFilamentColor(hex: string): boolean {
if (!hex || hex.length < 6) return false;
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
}
export function FilamentSlotCircle({ trayColor, trayType, isEmpty, slotNumber }: FilamentSlotCircleProps) {
return (
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2 flex items-center justify-center"
style={{
backgroundColor: trayColor ? `#${trayColor}` : (trayType ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
>
<span
className="text-[6px] font-bold leading-none select-none"
style={{ color: trayColor && isLightFilamentColor(trayColor) ? '#000' : '#fff' }}
>
{slotNumber}
</span>
</div>
);
}
+10
View File
@@ -445,6 +445,16 @@ export default {
clickToViewHmsErrors: 'Klicken, um HMS-Fehler anzuzeigen',
estimatedCompletion: 'Geschätzte Fertigstellungszeit',
slotOptions: 'Slot-Optionen',
// AMS hover popup
amsPopup: {
friendlyName: 'AMS-Name',
friendlyNamePlaceholder: 'z. B. AMS-Anzeigename',
serialNumber: 'Seriennummer',
firmwareVersion: 'Firmware',
save: 'Speichern',
clear: 'Löschen',
noEditPermission: 'Sie haben keine Berechtigung, AMS-Einheiten umzubenennen',
},
// Firmware modal
firmwareModal: {
title: 'Firmware-Update',
+10
View File
@@ -445,6 +445,16 @@ export default {
clickToViewHmsErrors: 'Click to view HMS errors',
estimatedCompletion: 'Estimated completion time',
slotOptions: 'Slot options',
// AMS hover popup
amsPopup: {
friendlyName: 'AMS Name',
friendlyNamePlaceholder: 'e.g. AMS Friendly Name',
serialNumber: 'Serial Number',
firmwareVersion: 'Firmware',
save: 'Save',
clear: 'Clear',
noEditPermission: 'You do not have permission to rename AMS units',
},
// Firmware modal
firmwareModal: {
title: 'Firmware Update',
+10
View File
@@ -445,6 +445,16 @@ export default {
clickToViewHmsErrors: 'Cliquez pour voir les erreurs HMS',
estimatedCompletion: 'Fin estimée',
slotOptions: 'Options du slot',
// AMS hover popup
amsPopup: {
friendlyName: 'Nom AMS',
friendlyNamePlaceholder: 'ex. Nom convivial AMS',
serialNumber: 'Numéro de série',
firmwareVersion: 'Firmware',
save: 'Enregistrer',
clear: 'Effacer',
noEditPermission: "Vous n'avez pas la permission de renommer les unités AMS",
},
// Firmware modal
firmwareModal: {
title: 'Mise à jour Firmware',
+10
View File
@@ -436,6 +436,16 @@ export default {
clickToViewHmsErrors: 'Clicca per vedere errori HMS',
estimatedCompletion: 'Tempo completamento stimato',
slotOptions: 'Opzioni slot',
// AMS hover popup
amsPopup: {
friendlyName: 'Nome AMS',
friendlyNamePlaceholder: 'es. Nome AMS amichevole',
serialNumber: 'Numero di serie',
firmwareVersion: 'Firmware',
save: 'Salva',
clear: 'Cancella',
noEditPermission: 'Non hai il permesso di rinominare le unità AMS',
},
// Firmware modal
firmwareModal: {
title: 'Aggiornamento Firmware',
+10
View File
@@ -408,6 +408,16 @@ export default {
},
estimatedCompletion: '完了予定時刻',
slotOptions: 'スロットオプション',
// AMS hover popup
amsPopup: {
friendlyName: 'AMS名',
friendlyNamePlaceholder: '例: AMS フレンドリー名',
serialNumber: 'シリアル番号',
firmwareVersion: 'ファームウェア',
save: '保存',
clear: 'クリア',
noEditPermission: 'AMS ユニットの名前を変更する権限がありません',
},
firmwareModal: {
title: 'ファームウェアアップデート',
titleUpToDate: 'ファームウェア情報',
+10
View File
@@ -445,6 +445,16 @@ export default {
clickToViewHmsErrors: 'Clique para ver erros do HMS',
estimatedCompletion: 'Tempo estimado de conclusão',
slotOptions: 'Opções de slot',
// AMS hover popup
amsPopup: {
friendlyName: 'Nome do AMS',
friendlyNamePlaceholder: 'ex.: Nome amigável do AMS',
serialNumber: 'Número de série',
firmwareVersion: 'Firmware',
save: 'Salvar',
clear: 'Limpar',
noEditPermission: 'Você não tem permissão para renomear unidades AMS',
},
// Firmware modal
firmwareModal: {
title: 'Atualização de Firmware',
+3 -2
View File
@@ -210,7 +210,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
{printerLabel} {slotLabel}
{printerLabel} {slotLabel}{assignment.ams_label ? ` (${assignment.ams_label})` : ''}
</span>
);
},
@@ -359,7 +359,8 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
if (!a) return '';
const isExt = a.ams_id === 254 || a.ams_id === 255;
const isHt = !isExt && a.ams_id >= 128;
return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}`;
const label = a.ams_label ? ` (${a.ams_label})` : '';
return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`;
},
label_weight: (s) => s.label_weight,
net: (s) => Math.max(0, s.label_weight - s.weight_used),
+247 -27
View File
@@ -72,6 +72,7 @@ import { PrintModal } from '../components/PrintModal';
import { PrinterInfoModal } from '../components/PrinterInfoModal';
import { getGlobalTrayId } from '../utils/amsHelpers';
import { getPrinterImage, getWifiStrength } from '../utils/printer';
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
// Complete Bambu Lab filament color mapping by tray_id_name
@@ -1289,6 +1290,201 @@ function mapModelCode(ssdpModel: string | null): string {
return modelMap[ssdpModel] || ssdpModel;
}
// ─── AMS Name Hover Card ──────────────────────────────────────────────────────
// Wraps the AMS label (e.g. "AMS-A") and shows a popup with:
// • User-defined friendly name (editable, protected by printers:update)
// • AMS serial number
// • AMS firmware version
export function AmsNameHoverCard({
ams,
printerId,
label,
amsLabels,
canEdit,
onSaved,
children,
}: {
ams: import('../api/client').AMSUnit;
printerId: number;
label: string; // auto-generated label, e.g. "AMS-A"
amsLabels?: Record<number, string>;
canEdit: boolean;
onSaved: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
const [position, setPosition] = useState<'top' | 'bottom'>('top');
const [editValue, setEditValue] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isInputFocused, setIsInputFocused] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (isVisible) {
setEditValue(amsLabels?.[ams.id] ?? '');
setSaveError(null);
requestAnimationFrame(() => {
if (triggerRef.current && cardRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const spaceAbove = rect.top - 56;
const spaceBelow = window.innerHeight - rect.bottom;
setPosition(spaceAbove < cardRef.current.offsetHeight + 12 && spaceBelow > spaceAbove ? 'bottom' : 'top');
}
});
}
}, [isVisible, amsLabels, ams.id]);
const handleMouseEnter = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
};
const handleMouseLeave = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (!isInputFocused) {
timeoutRef.current = setTimeout(() => setIsVisible(false), 200);
}
};
useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);
const handleSave = async () => {
if (!canEdit) return;
setIsSaving(true);
setSaveError(null);
try {
const trimmed = editValue.trim();
if (trimmed) {
await api.saveAmsLabel(printerId, ams.id, trimmed, ams.serial_number);
} else {
await api.deleteAmsLabel(printerId, ams.id, ams.serial_number);
}
onSaved();
setIsVisible(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setIsSaving(false);
}
};
const handleClear = async () => {
if (!canEdit) return;
setIsSaving(true);
setSaveError(null);
try {
await api.deleteAmsLabel(printerId, ams.id, ams.serial_number);
onSaved();
setIsVisible(false);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setIsSaving(false);
}
};
return (
<div
ref={triggerRef}
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
{isVisible && (
<div
ref={cardRef}
className={`
absolute left-0 z-50
${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
animate-in fade-in-0 zoom-in-95 duration-150
`}
style={{ maxWidth: 'calc(100vw - 24px)' }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm p-2.5 space-y-2">
{/* AMS auto-label */}
<div className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{label}</div>
{/* Serial number */}
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] tracking-wide text-bambu-gray font-medium shrink-0">
{t('printers.amsPopup.serialNumber')}
</span>
<span className="text-[10px] text-white font-mono truncate">{ams.serial_number || '—'}</span>
</div>
{/* Firmware version */}
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] tracking-wide text-bambu-gray font-medium shrink-0">
{t('printers.amsPopup.firmwareVersion')}
</span>
<span className="text-[10px] text-white font-mono truncate">{ams.sw_ver || '—'}</span>
</div>
{/* Divider */}
<div className="h-px bg-bambu-dark-tertiary/50" />
{/* Friendly name editor */}
<div className="space-y-1">
<span className="text-[10px] text-bambu-gray font-medium block">
{t('printers.amsPopup.friendlyName')}
</span>
<input
type="text"
value={editValue}
onChange={(e) => canEdit && setEditValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
onFocus={() => setIsInputFocused(true)}
onBlur={() => {
setIsInputFocused(false);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setIsVisible(false), 200);
}}
placeholder={canEdit ? t('printers.amsPopup.friendlyNamePlaceholder') : (amsLabels?.[ams.id] || '—')}
disabled={!canEdit}
title={!canEdit ? t('printers.amsPopup.noEditPermission') : undefined}
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-xs text-white placeholder-bambu-gray/60 focus:outline-none focus:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed"
maxLength={100}
/>
{canEdit && (
<div className="space-y-1">
{saveError && (
<p className="text-[10px] text-red-400 break-words">{saveError}</p>
)}
<div className="flex gap-1 justify-end">
<button
onClick={handleSave}
disabled={isSaving}
className="px-2 py-0.5 text-[10px] bg-bambu-green text-white rounded hover:bg-bambu-green/80 disabled:opacity-50"
>
{t('printers.amsPopup.save')}
</button>
{amsLabels?.[ams.id] && (
<button
onClick={handleClear}
disabled={isSaving}
className="px-2 py-0.5 text-[10px] bg-bambu-dark-tertiary text-bambu-gray rounded hover:bg-bambu-dark-tertiary/70 disabled:opacity-50"
>
{t('printers.amsPopup.clear')}
</button>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
function PrinterCard({
printer,
hideIfDisconnected,
@@ -1498,6 +1694,13 @@ function PrinterCard({
staleTime: 2 * 60 * 1000, // 2 minutes
});
// Fetch user-defined AMS friendly names from the database
const { data: amsLabels, refetch: refetchAmsLabels } = useQuery({
queryKey: ['amsLabels', printer.id],
queryFn: () => api.getAmsLabels(printer.id),
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Cache WiFi signal to prevent it disappearing on updates
const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
useEffect(() => {
@@ -2789,9 +2992,19 @@ function PrinterCard({
{/* Header: Label + Stats (no icon) */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-white font-medium">
{getAmsLabel(ams.id, ams.tray.length)}
</span>
{/* AMS name — hover to see serial, firmware, and edit friendly name */}
<AmsNameHoverCard
ams={ams}
printerId={printer.id}
label={getAmsLabel(ams.id, ams.tray.length)}
amsLabels={amsLabels}
canEdit={hasPermission('printers:update')}
onSaved={refetchAmsLabels}
>
<span className="text-[10px] text-white font-medium cursor-default select-none">
{amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
</span>
</AmsNameHoverCard>
{isDualNozzle && (isLeftNozzle || isRightNozzle) && (
<NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
)}
@@ -2884,13 +3097,12 @@ function PrinterCard({
<div
className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
style={{
backgroundColor: tray?.tray_color ? `#${tray.tray_color}` : (tray?.tray_type ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
{/* Filament color circle with 1-based slot number centered inside */}
<FilamentSlotCircle
trayColor={tray?.tray_color}
trayType={tray?.tray_type}
isEmpty={isEmpty}
slotNumber={slotIdx + 1}
/>
<div className="text-[9px] text-white font-bold truncate">
{tray?.tray_type || '—'}
@@ -3107,13 +3319,12 @@ function PrinterCard({
<div
className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
style={{
backgroundColor: tray?.tray_color ? `#${tray.tray_color}` : (tray?.tray_type ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
{/* Filament color circle with 1-based slot number centered inside */}
<FilamentSlotCircle
trayColor={tray?.tray_color}
trayType={tray?.tray_type}
isEmpty={isEmpty}
slotNumber={1}
/>
<div className="text-[9px] text-white font-bold truncate">
{tray?.tray_type || '—'}
@@ -3137,9 +3348,19 @@ function PrinterCard({
<div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
{/* Row 1: Label + Nozzle */}
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] text-white font-medium">
{getAmsLabel(ams.id, ams.tray.length)}
</span>
{/* AMS name — hover to see serial, firmware, and edit friendly name */}
<AmsNameHoverCard
ams={ams}
printerId={printer.id}
label={getAmsLabel(ams.id, ams.tray.length)}
amsLabels={amsLabels}
canEdit={hasPermission('printers:update')}
onSaved={refetchAmsLabels}
>
<span className="text-[10px] text-white font-medium cursor-default select-none">
{amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
</span>
</AmsNameHoverCard>
{isDualNozzle && (isLeftNozzle || isRightNozzle) && (
<NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
)}
@@ -3361,13 +3582,12 @@ function PrinterCard({
const isEmpty = !extTray.tray_type;
const extSlotContent = (
<div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
<div
className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
style={{
backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : (extTray.tray_type ? '#333' : 'transparent'),
borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
borderStyle: isEmpty ? 'dashed' : 'solid',
}}
{/* Filament color circle with 1-based slot number centered inside */}
<FilamentSlotCircle
trayColor={extTray.tray_color}
trayType={extTray.tray_type}
isEmpty={isEmpty}
slotNumber={slotTrayId + 1}
/>
<div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
{extTray.tray_type || '—'}