diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e3871bf5..1446b7e57 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,16 @@ All notable changes to Bambuddy will be documented in this file.
- **Backend dependency security floor raises (cryptography / python-multipart / starlette)** — pip-audit December 2026 cycle surfaced six advisories across three direct deps; floors in `requirements.txt` lifted to the documented fix releases, plus one transitive co-bump for resolver compatibility. **`cryptography` 46.0.7 → 48.0.1 floor** (resolver picks 49.0.0 within the new floor) — clears GHSA-537c-gmf6-5ccf (non-contiguous Python buffer handling that could overflow on APIs accepting buffer protocol input). **Release-notes audit (done before bump):** v47.0.0 dropped Python 3.8 + OpenSSL 1.1.x + binary elliptic curves (SECT*) + Camellia + CFB/OFB/CFB8 modes (moved to `cryptography_decrepit`); v48.0.0 dropped `PUBLIC_KEY_TYPES` / `PRIVATE_KEY_TYPES` type aliases. Bambuddy's grep is clean across every one of those: `core/encryption.py` uses Fernet (AES-128-CBC + HMAC), `services/spoolbuddy_ssh.py` uses ed25519, `services/virtual_printer/certificate.py` uses RSA + x509 + ExtendedKeyUsageOID. Python 3.13 + OpenSSL 3.x on container, so the version-floor bumps are no-ops for us. **`python-multipart` 0.0.27 → 0.0.31 floor** (resolver picks 0.0.32) — clears CVE-2026-53538/53539/53540 in the multipart parser surface (boundary length capped at 256 bytes, RFC 2231 continuation handling, Content-Length non-negative validation, bounded header field name size before validation). **Behavioural changes audited:** 0.0.30 stopped recognising RFC 2231/5987 extended `filename*` / `name*` parameters in incoming bodies — Bambuddy emits these on outgoing Content-Disposition response headers (`utils/http.py:17`) but doesn't parse them on the request side, and clients that include both `filename=` and `filename*=` keep working via the plain `filename=` fallback (slight cosmetic difference for non-ASCII filenames in uploads). 0.0.30 also tightened form-urlencoded parsing to treat only `&` as field separator — every Bambuddy client (browser, BambuStudio, OrcaSlicer) already uses `&`. **`starlette` 1.1.0 → 1.3.1 floor** — clears CVE-2026-54282/54283 (FormParser `max_part_size` / `max_fields` limits now actually enforced after being declared-but-ignored in earlier releases; `StaticFiles.lookup_path` rejects absolute paths; `FileResponse` clamps oversized suffix range requests; `URL.replace()` IndexError fix). **Critical pre-bump check:** the newly-enforced `max_part_size=1MB` default would have broken every file upload (`UploadFile = File(...)` in `inventory.py:1127`, `projects.py:886/1053/1780`, `library.py:1787`, `local_presets.py:82`, `external_links.py:166`, `local_backup.py`) if it applied to file streams. Inspected the `MultiPartParser.on_part_data` source: the size check at `if self._current_part.file is None:` only fires for **text** form fields, not file streams — so file uploads of arbitrary size still pass through unaffected. Text form bodies in Bambuddy are login credentials and similar small values, well under the 1MB ceiling. **Side rename:** `backend/app/api/routes/mfa.py:470/1364/1428` replaces 3 references of `status.HTTP_422_UNPROCESSABLE_ENTITY` (deprecated in starlette 1.3.x) with `HTTP_422_UNPROCESSABLE_CONTENT`. Same 422 wire status; silences the 3 deprecation warnings under our own ownership (the two remaining warnings come from FastAPI internals — upstream's to fix). **`pyopenssl` 26.0.0 → 26.3.0 floor** — **NOT a security fix**; required because pyOpenSSL `<26.3.0` caps `cryptography<47` in its install_requires, so without an explicit floor the resolver either downgrades cryptography below the GHSA-537c-gmf6-5ccf fix line or installs an inconsistent pair (pip's resolver warns but proceeds). Bambuddy has no direct `from OpenSSL ...` imports — pyOpenSSL is pulled transitively by `asyncssh` + `pywebpush`. **Verification:** `pip-audit` clean, `pip check` clean, `ruff check backend/` clean, backend `pytest -n 30` 6167/6167 in 86.55s. No DB migration, no API surface change, no permission change, no frontend change.
### Added
+- **AMS Filament Backup is now first-class across the deficit check, the printer card, and a new BambuStudio-style backup modal (#1762, reported by @jpcast2001 + @Arn0uDz)** — Four tightly coupled changes that close the gap reporter @jpcast2001 hit on the dual-AMS X1C farm. Reporter scenario: PLA Basic in AMS-1 slot 1 with low remaining grams, the same PLA Basic in AMS-2 slot 1 with plenty — Bambuddy still blocked the print with an "insufficient filament" warning because per-slot accounting never noticed the backup peer. Reporter disabled `disable_filament_warnings` as a workaround; @Arn0uDz hit the same shape on a different printer and noted "Print Anyway" didn't unstick them either. The single global AMS Filament Backup toggle that shipped in 0.2.5b1 (#1766) was the prerequisite for the firmware-level switch, but every Bambuddy surface still treated each slot as isolated. **What's new on each surface:**
+
+ **Backup-aware deficit aggregation (the load-bearing fix).** `backend/app/services/filament_deficit.py::compute_deficit_for_queue_item` now reads `PrinterState.ams_filament_backup` via `printer_manager.get_status` and, when backup is ON, pools `remaining_grams` across every same-material assigned spool on the printer before deciding whether to block. Material identity uses the firmware's actual rule: same Bambu filament preset ID (`Spool.slicer_filament`, e.g. `GFA00`) AND same colour (with `1A1A1AFF` normalised to match `1A1A1A` — alpha stripped, hex uppercased). The preset identifies the filament profile (PETG HF, PLA Basic, etc.); the colour pins the variant. Three PETG HF spools in different colours all share the same preset but absolutely don't back each other up — the firmware would correctly swap PETG HF but the print would change colour mid-run. User-tagged spools without a preset get a unique-per-spool key so they never pair with anything else, matching the firmware: Bambu's backup logic relies on the preset, and grouping on cosmetic material+colour match alone would let two visually-identical but materially-different spools be treated as backups. Same `(catalog-id, colour)` rule on the Spoolman side via `filament.id` + `color_hex`; spools linked to different filament catalog entries never pool even if their material+colour strings match. **Dual-extruder scoping is load-bearing here:** H2D / H2C / X2D firmware cannot cross extruders even when bit 18 of `print.cfg` is set, so the pool is per-extruder-side via `PrinterStatus.ams_extruder_map` plus `is_dual_nozzle_model()`. Single-extruder printers collapse everything to one pool and ignore the map. The check still emits per-slot `FilamentDeficit` rows when the *total* required of a material on an extruder side exceeds the *total* available of that material, so the UI's "slot X is short" message still resolves to a specific slot the user can act on — it just doesn't fire spuriously when the firmware will actually save them.
+
+ **BambuStudio-style backup modal opens from the badge.** The existing AMS Backup badge on the Filaments section header (#1766) now opens a dedicated modal instead of toggling state on click. The modal renders one SVG ring graphic per backup pair — each ring filled with the filament colour, the material name + rotation count (`N× ↻`) in the centre, and member slot labels distributed around the colour band on rounded contrast-aware pills (semi-opaque black on bright fills, semi-opaque white on dark) so the labels stay legible regardless of the spool colour. Closely modelled on Bambu Studio's "Auto Refill" widget. Lone slots are intentionally suppressed — the ring graphic is the answer to "which slots will save me when this one runs out"; everything else is visual noise. On dual-extruder printers (H2D / H2C / X2D), each ring carries a compact `R` / `L` badge in the top-left corner instead of section headers — and the badge ONLY appears when the extruder map carries TWO distinct values across the AMS units, so single-nozzle printers misflagged as dual or printers with routing data not yet reported collapse cleanly to no-badge rendering. Modal closes on **Esc keypress** (window-level listener registered while open, cleaned up on close), click-outside, or the close button. Theme-aware via CSS variables (`var(--bg-secondary)` / `var(--text-primary)` / etc.) matching `AMSHistoryModal`, so the modal follows whichever background variant the user has picked (neutral / warm / cool / oled / slate / forest). The badge itself stays in the Filaments section header where #1766 put it — its `onClick` was rewired from "directly toggle the backup state" to "open the modal", with the same `setAmsFilamentBackup` mutation hooked to the toggle inside the modal. The new `computeBackupGroups(amsUnits, amsExtruderMap, isDualNozzle): BackupGroup[]` helper in `utils/amsHelpers.ts` is the modal's data source — it returns one entry per non-empty slot, sorted with pairs first, then by material name, then by global tray id for deterministic rendering. Identity uses the same strict `(preset, colour)` rule as the backend. HT AMS (single-tray modules with `ams_id ≥ 128`) participate in groupings via `getGlobalTrayId`, so an HT slot can pair with a regular AMS slot when both hold the same preset and colour. Defensive dedup by `ams.id` (first occurrence wins) hardens the helper against duplicate entries that have been observed in the wild on VP-aggregated switch printers and MQTT partial-update edge cases — without the dedup, a single physical slot could render in two different rings.
+
+ **Active-print per-slot mapping pill while RUNNING / PAUSE.** While the printer is mid-print, each slot tile referenced by `PrinterStatus.ams_mapping` (already on the wire, from the slicer's filament-map captured during dispatch) gets a small "P1 / P2 / P3 …" pill in the top-right corner — opposite the backup-group dot — naming which print-slot is mapped to this AMS slot. Catches the secondary report from @jpcast2001's comment 2 verbatim: queue job set for "any X1C", scheduler bumped it to a printer with mismatched filament, no way to verify mid-print whether the right slots are loaded. With the pill the mismatch is visible the instant the print starts. The existing single-slot `ring-2 ring-bambu-green` highlight for `effectiveTrayNow` keeps its meaning ("the currently-active extrusion source RIGHT NOW") — the pill is per-slot static "this slot is filament N in the active print," not per-tick dynamic.
+
+ **Print Anyway diagnostic log (Arn0uDz follow-up).** `_block_on_filament_deficit` in `print_scheduler.py:1983` now logs at INFO when it honours `item.skip_filament_check`, so a future "Print Anyway didn't work" report (the third commenter on #1762 hit this shape) has an actionable line in the standard support bundle without needing debug logging enabled. The route-side log at `print_queue.py:1278` is unchanged. Without logs from the original report we can't isolate the user's failure mode (the wire path on both ends still looks correct on inspection), so this is the minimal trace required to investigate the next occurrence — bundled in the same drop because Block 1 makes the original symptom disappear for users who had backup ON anyway. **Tests.** 8 new backend cases in `test_filament_deficit.py::TestFilamentDeficitBackupAware` pin every dimension: pool covers the assigned-slot shortfall → no deficit (the reporter scenario, with matching `slicer_filament` preset + matching colour); pool insufficient → deficit emitted with the correct slot id; peer slot holds a DIFFERENT preset → no pool, deficit fires; backup OFF → strict regression with the pre-#1762 per-slot accounting (using identical inputs to the "pool covers" case but flipping the toggle); dual-extruder printer with a peer on the OPPOSITE side → deficit fires because the firmware can't cross; STRICT-rule — two spools with material+colour match but NO preset must NEVER pair; COLOUR-strict — same preset + DIFFERENT colours must NOT pool (the reporter screenshot scenario, three PETG HF in different colours); COLOUR normalisation — 6-char and 8-char hex of the same RGB pool correctly (`000000` matches `000000FF`). 13 frontend cases in `PrintersPageBackupGroups.test.ts` pin `computeBackupGroups`: empty for missing input; ignores empty slots; pairs via preset; no-preset spools NEVER pair even on attribute-tuple match; different presets never cross; SAME preset + DIFFERENT colours don't pair; colour-hex 6-char and 8-char normalisation; lone slots returned alongside pairs in the same list; dual-extruder scopes per-side both ways; HT AMS pairs with regular AMS via `getGlobalTrayId`; preserves display name + tray colour for the modal swatch; DEFENSIVE dedup of duplicate `ams.id` entries (first wins). 10 modal render cases in `AmsBackupModal.test.tsx`: closed → null; ring renders for pairs and OMITS lone slots; **Esc keypress closes the modal**; Esc is a no-op after the modal closes (listener actually unmounts); toggle reflects ON state + fires onToggle(false) on click; toggle disabled when state unknown (A1 family); toggle disabled when permission missing; no-pairs empty state when no pair can form; R/L badges render when extruder map carries two distinct values; R/L badges absent when the map collapses to one extruder. Existing 8 `test_filament_deficit.py` + 60 `PrintersPage.test.tsx` cases stay green — the no-backup path is a strict no-op vs the pre-#1762 logic. **i18n.** 12 new keys × 11 locales for the modal + the active-print pill (`printers.amsBackup.modalTitle / modalHelp / modalNoSlots / modalNoPairs / stateOn / stateOff / stateUnknown / extruderRightShort / extruderLeftShort`, plus `printers.activeJobSlot.title / ariaLabel`) translated in de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW. Parity check 5253 leaves per locale, no English fallback; "AMS Filament Backup" is a Bambu product/firmware name and is allowlisted as a cognate where the European locales keep it verbatim. **Scope.** No DB migration, no new permission. The global Filament Backup badge stays where #1766 put it (Filaments section header on the printer card) — firmware reality is one bit on `print.cfg`, and moving the toggle per-AMS would misrepresent that. The badge click no longer toggles directly; it opens the modal, where the same `setAmsFilamentBackup` mutation is wired to the toggle. No schema change to `FilamentDeficit` — same shape, same wire payload, same 409 response under `code: insufficient_filament`. No change to the `disable_filament_warnings` setting (#720) — when on, the deficit check is still a no-op regardless of backup state. **Behaviour shift worth flagging.** Pre-PR, prints could be blocked with "insufficient filament" even when the firmware would actually have switched to a same-`(preset, colour)` peer mid-print. Post-PR, those prints dispatch. Users with backup misconfigured at the firmware level (e.g. FTS routing wrong) may see prints dispatch that previously got blocked at the deficit check; the printer would then fail mid-run rather than at queue-start time. The trade-off is correct — the warning shouldn't fire when backup will save you — but worth surfacing for anyone debugging post-upgrade.
+
- **Dedicated "AI Failure Detection" notification event (#1794, reported by @maziggy from a user report)** — Obico failure detection now fires its own notification event (`on_ai_failure_detection`) instead of riding the multiplexed `on_printer_error` toggle. Reporter (P1S, Discord provider) had Obico enabled with `obico_action=notify`, detection was firing correctly per the logs, every other Discord notification was working — but spaghetti detections never reached Discord. **Root cause.** `obico_actions._notify` at `obico_actions.py:75` was calling `notification_service.on_printer_error(..., error_type="ai_failure_detection")`. The notification service's provider filter at `notification_service.py:722-725` requires the SUBSCRIBED-event boolean column to be True; the `on_printer_error` column defaults to False; the reporter's Discord provider was created without explicitly enabling Printer Error. The user couldn't have found the right toggle even if they'd known to look — the UI labels it "Printer Error" with no hint that flipping it also subscribes to AI detection. The same toggle multiplexed three distinct events (HMS hardware errors at `main.py:1248` + Obico spaghetti + a `error_type="ai_failure_detection"` discriminator passed in the variables payload), so a user who wanted spaghetti alerts but not chamber-fan-stalled HMS pages had no way to express that. **Fix.** New `on_ai_failure_detection` Boolean column on `notification_providers` (defaults False — matches the conservative default of every other opt-in event); new `notification_service.on_ai_failure_detection(printer_id, printer_name, task_name, confidence, action, db, image_data)` method following the exact shape of `on_printer_error` (mirrors variable handling, template fan-out, provider filter, fail-open under quiet-hours / digest); new `ai_failure_detection` template entry seeded by `seed_notification_templates` with variables `{printer}`, `{task_name}`, `{confidence}`, `{action}`. The seeder only adds templates whose `event_type` is missing, so existing installations get the new template on next start without clobbering customised ones. `obico_actions._notify` swapped to the new method. **Migration.** Branched SQLite (`DEFAULT 0`) vs Postgres (`DEFAULT false`) per the existing stock-alert migration shape at `database.py:2750` — Postgres rejects `DEFAULT 0` for BOOLEAN columns. Existing providers receive the column with the conservative False default; they continue NOT receiving Obico notifications UNTIL they explicitly toggle the new "AI Failure Detection" event ON. This is the intended UX: previously the toggle was on `Printer Error`, which the reporter had OFF, so today they get nothing; after this change they still get nothing until they opt in via the dedicated toggle, but now they can find the toggle without trial-and-error. **Frontend.** New toggle row in `NotificationProviderCard.tsx` (between Printer Error and Low Filament) with a description line "Notify when Obico AI detects a possible print failure" so users discover the link to Obico without having to read source. New summary badge ("AI Failure Detection" in fuchsia) in the collapsed card view so admins can see at a glance which providers route AI alerts. New toggle in `AddNotificationModal.tsx` Printer Status section with matching state hook (`onAiFailureDetection`) wired through the create + update payload. ntfy per-event priority block also picks up the new event when enabled, matching how Printer Error and the stock-alert events behave there. **Schema.** `NotificationProvider` model + `NotificationProviderBase`/`NotificationProviderUpdate` schemas + `_provider_to_dict` route serialiser + create route + PATCH route (the latter uses `model_dump(exclude_unset=True)` so it picks up the new field automatically). Frontend `NotificationProvider` type + the update-payload variant. **i18n.** Two new keys — `notifications.aiFailureDetection` (label) and `notifications.aiFailureDetectionDescription` (help text) — translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5242 leaves per locale, no English fallback. **Tests.** 4 new backend cases in `test_notification_service.py::TestAIFailureDetectionNotifications` (dispatch uses the new event field — NOT the legacy multiplexed one; provider with only `on_printer_error=True` is NOT notified — the regression guard for the reporter's symptom; variables include task_name + 2-decimal-formatted confidence + action; empty task_name falls back to "current job"). 3 new backend cases in `test_obico_actions.py` (`execute_action(action='notify')` calls `on_ai_failure_detection` and explicitly does NOT call `on_printer_error`; the `pause` action still pauses + notifies; notification-service exceptions are swallowed so a transient Discord blip can't kill the Obico detection loop). 5 new frontend cases — 4 in `NotificationProviderCardAiFailureDetection.test.tsx` (badge renders when ON; absent when OFF; toggle appears in expanded settings; toggling PATCHes the correct field and explicitly NOT `on_printer_error`) and 3 in `AddNotificationModal.test.tsx` (toggle renders in Printer Status section; save persists the new field without touching `on_printer_error`; ntfy priority block includes the event when enabled). Existing 87 `test_notification_service.py` + 52 Obico tests + 65 `NotificationProviderCard*` / `AddNotificationModal*` tests still green. Backend `pytest -n 30` clean; ruff clean; `npm run build` clean; ESLint clean. **Scope.** No change to HMS hardware-error notifications — `main.py::on_printer_error` callers still fire the `on_printer_error` event with `error_type` shapes like `"AMS Error"` / `"Heating Error"`, unchanged. The `on_printer_error` column stays on the table (default False, used for HMS only). Users who had it ON for HMS errors keep getting HMS notifications; what they LOSE is silent AI-failure dispatch on the same toggle, which most users with HMS-on never received anyway because `error_type="ai_failure_detection"` was the same value `obico_actions._notify` hardcoded. The full Obico action surface (`notify` / `pause` / `pause_and_off`) is unchanged on the dispatch side — `execute_action` still pauses + cuts plug power for `pause_and_off`; the only thing that moved is which notification-service method runs the fan-out.
- **Page-wide drag-and-drop upload on the File Manager (#1510, requested by @maikolscripts)** — File Manager gains the same drag-and-drop upload surface that the Archives page has had: drop any file anywhere on the page and the upload modal opens pre-populated with the dropped files, no need to click the **Upload Files** button first. The hardcoded `"Upload 3MF"` flow was the only path before this change. Unlike the Archives variant — which filters dropped files to `.3mf` only — the File Manager drop zone accepts whatever the upload modal itself accepts (3MF, STL, ZIP, images), so the page-wide surface is never more restrictive than the button it shortcuts. Permission-gated on `library:upload` so a viewer-tier user can't accidentally trigger the overlay. **Shared hook.** `frontend/src/hooks/usePageFileDrop.ts` is the new home for the drag-handler set — `isDraggingOver` state, `dragHandlers` to spread on the wrapper, optional `extensions` filter, optional `onRejected` callback for "you dropped something we won't accept" toasts, `disabled` flag for permission gating. Archives and File Manager both consume it; future drop-zones can opt in without re-implementing the cancel-safe logic. **`FileUploadModal.initialFiles` prop.** Modal accepts a `File[]` to pre-seed itself on first mount via a `seededInitialRef` guard so the same files don't re-add on subsequent renders. Existing manual-open paths (Upload Files button) pass nothing and behave unchanged. **i18n.** New key `fileManager.releaseToUpload` translated in all 11 locales (en: Release to upload, de: Loslassen zum Hochladen, es: Suelte para subir, fr: Relâcher pour téléverser, it: Rilascia per caricare, ja: 離してアップロード, ko: 놓아서 업로드, pt-BR: Solte para enviar, tr: Yüklemek için bırakın, zh-CN: 释放以上传, zh-TW: 釋放以上傳); existing `fileManager.dropFilesHere` reused. Parity 5240 leaves × 11 green, no English fallback. **Tests.** 13 new cases in `src/__tests__/hooks/usePageFileDrop.test.tsx` covering: overlay on dragenter, non-file payload ignored, child-element dragLeave keeps overlay (relatedTarget inside wrapper), outside-element dragLeave hides it, null relatedTarget hides it (cursor left window), document drop / dragend / Escape all reset (the three cancel paths the prior inline implementation missed — see the Fixed entry), drop with mixed file types filters by extension, onRejected fires when extension filter drops everything, disabled is a no-op, overlay clears on successful drop. Existing 85 cases across ArchivesPage / FileManagerPage / FileManagerExternalFolder vitest still green. ESLint clean; `npm run build` clean.
diff --git a/backend/app/services/filament_deficit.py b/backend/app/services/filament_deficit.py
index d55b0b1df..5271a3e5d 100644
--- a/backend/app/services/filament_deficit.py
+++ b/backend/app/services/filament_deficit.py
@@ -30,6 +30,7 @@ from __future__ import annotations
import json
import logging
+from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
@@ -148,6 +149,88 @@ async def _warnings_disabled(db: AsyncSession) -> bool:
return False
+def _normalize_color_for_id(raw: str | None) -> str:
+ """Canonicalise a hex colour for identity comparison.
+
+ Strips the leading ``#``, uppercases, and drops the alpha channel when
+ the hex is 8 chars long (``RRGGBBAA``) so a fully-opaque 8-char hex
+ matches a 6-char hex of the same RGB. Empty / None → empty string.
+ """
+ s = (raw or "").strip().lstrip("#").upper()
+ if len(s) == 8: # RRGGBBAA → strip alpha
+ s = s[:6]
+ return s
+
+
+def _material_identity_internal(spool) -> str:
+ """Strict same-material key for backup-peer matching in internal mode.
+
+ Requires a Bambu filament preset ID (``slicer_filament``, e.g. ``GFA00``)
+ AND a matching colour. The preset identifies the filament profile (PETG
+ HF, PLA Basic, etc.) — same hot-end behaviour — but the firmware's
+ switch logic also requires the spool to be the same colour (otherwise
+ every PETG HF spool would back every other PETG HF spool regardless of
+ colour, which would dye prints mid-run). Spools without a preset
+ (user-tagged / non-Bambu) get a per-spool unique key so they NEVER
+ pair with anything else; without the Bambu preset the firmware can't
+ trust the backup decision.
+ """
+ preset = (spool.slicer_filament or "").strip() if spool else ""
+ if preset:
+ color = _normalize_color_for_id(spool.rgba if spool else None)
+ return f"preset:{preset}|color:{color}"
+ # Unique-per-spool key prevents grouping. Use the spool's primary key so
+ # the same spool always resolves to the same key within a request.
+ spool_id = getattr(spool, "id", None) if spool else None
+ return f"unmatched:{spool_id}"
+
+
+def _material_identity_spoolman(spool: dict | None) -> str:
+ """Strict same-material key for backup-peer matching in Spoolman mode.
+
+ Two spools pair only when they reference the same Spoolman ``filament``
+ catalog entry (same ``filament.id``) AND share the same colour. The
+ catalog entry pins the profile (PETG HF / PLA Basic / ...); the colour
+ pins the variant. Spools without a resolvable filament id get a
+ per-spool unique key so they never pair.
+ """
+ if not spool:
+ return "unmatched:none"
+ filament = spool.get("filament") or {}
+ fil_id = filament.get("id")
+ if isinstance(fil_id, (int, str)) and str(fil_id).strip():
+ # Prefer the per-spool override colour when set (Spoolman lets the user
+ # tag a spool with a colour distinct from the filament catalog
+ # default); fall back to the filament catalog colour.
+ color = _normalize_color_for_id(
+ (spool.get("color_hex") if isinstance(spool.get("color_hex"), str) else None) or filament.get("color_hex")
+ )
+ return f"filament:{fil_id}|color:{color}"
+ spool_id = spool.get("id")
+ return f"unmatched:{spool_id}"
+
+
+def _ams_id_from_global(global_tray_id: int) -> int:
+ """Inverse of ``_global_to_ams_key`` returning ams_id only."""
+ return _global_to_ams_key(global_tray_id)[0]
+
+
+def _extruder_side_for_ams(
+ ams_id: int,
+ ams_extruder_map: dict[str, int],
+ is_dual_extruder: bool,
+) -> int:
+ """Resolve the extruder index (0=right, 1=left) for a given AMS unit.
+
+ Single-extruder printers collapse everything to 0. On dual-extruder
+ printers (H2D / H2C / X2D), the firmware can't cross extruders even with
+ AMS Filament Backup ON, so the pool must be scoped per-side.
+ """
+ if not is_dual_extruder:
+ return 0
+ return int(ams_extruder_map.get(str(ams_id), 0))
+
+
def _parse_ams_mapping(raw: str | None) -> list[int] | None:
if not raw:
return None
@@ -160,6 +243,33 @@ def _parse_ams_mapping(raw: str | None) -> list[int] | None:
return [v for v in parsed if isinstance(v, int)]
+async def _get_printer_backup_context(
+ printer_id: int,
+) -> tuple[bool, dict[str, int], bool]:
+ """Return ``(backup_on, ams_extruder_map, is_dual_extruder)`` for the printer.
+
+ Read from the live MQTT state via ``printer_manager`` (no DB round-trip).
+ Defaults conservatively to ``backup_on=False`` when the state is missing
+ or the printer is offline — same fallback as today (per-slot deficit
+ accounting), so an offline printer is never treated as backup-capable.
+ """
+ try:
+ from backend.app.services.printer_manager import printer_manager
+ from backend.app.utils.printer_models import is_dual_nozzle_model
+ except ImportError:
+ return False, {}, False
+
+ state = printer_manager.get_status(printer_id)
+ if state is None:
+ return False, {}, False
+
+ backup_on = state.ams_filament_backup is True
+ ams_extruder_map = dict(state.ams_extruder_map or {})
+ model = printer_manager.get_model(printer_id)
+ is_dual = bool(model and is_dual_nozzle_model(model))
+ return backup_on, ams_extruder_map, is_dual
+
+
async def compute_deficit_for_queue_item(
db: AsyncSession,
item: PrintQueueItem,
@@ -178,6 +288,14 @@ async def compute_deficit_for_queue_item(
before dispatch; until it does we cannot map slot → tray.
* Spoolman mode is on but the Spoolman server is unreachable. We do not
wedge the queue on a network blip.
+
+ #1762: when the printer reports ``ams_filament_backup=True`` in MQTT
+ status, available material is pooled across ALL same-material spools on
+ the printer (within the same extruder side for dual-nozzle models, since
+ firmware can't cross extruders even with the backup bit set). Per-slot
+ shortfalls are then only emitted if the POOL is too small for the
+ print's total required of that material — matching how the printer
+ actually behaves with Filament Backup ON.
"""
if await _warnings_disabled(db):
return []
@@ -210,8 +328,27 @@ async def compute_deficit_for_queue_item(
return []
spoolman_mode = await _is_spoolman_mode(db)
+ backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(item.printer_id)
+
+ # ------------------------------------------------------------------ phase 1
+ # Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
+ # Slot identity is the identity of the spool *assigned to that slot*. A
+ # ``None`` remaining means "couldn't determine" — treated as "no deficit"
+ # below (preserved from pre-#1762 behaviour for non-backup paths too).
+ @dataclass
+ class _ReqRow:
+ slot_id: int
+ ams_id: int
+ tray_id: int
+ global_tray_id: int
+ required: float
+ identity: str
+ remaining: float | None
+ filament_type: str
+ extruder: int
+
+ resolved: list[_ReqRow] = []
- deficits: list[FilamentDeficit] = []
for req in requirements:
slot_id = req.get("slot_id")
used_grams = req.get("used_grams")
@@ -227,6 +364,7 @@ async def compute_deficit_for_queue_item(
continue
ams_id, tray_id = _global_to_ams_key(global_tray_id)
+ identity = "attrs:|||"
remaining: float | None = None
if spoolman_mode:
sm_result = await db.execute(
@@ -239,7 +377,32 @@ async def compute_deficit_for_queue_item(
sm_assignment = sm_result.scalar_one_or_none()
if sm_assignment is None:
continue
- remaining = await _spoolman_remaining_grams(sm_assignment.spoolman_spool_id)
+ # Live remaining_weight from Spoolman. The fetch also resolves the
+ # filament identity for pooling (material + colour + name).
+ from backend.app.services.spoolman import (
+ SpoolmanClientError,
+ SpoolmanNotFoundError,
+ get_spoolman_client,
+ )
+
+ try:
+ client = await get_spoolman_client()
+ spool_dict = await client.get_spool(sm_assignment.spoolman_spool_id) if client else None
+ except (SpoolmanNotFoundError, SpoolmanClientError):
+ spool_dict = None
+ except Exception as e:
+ logger.debug("Spoolman fetch failed for spool %s: %s", sm_assignment.spoolman_spool_id, e)
+ spool_dict = None
+ if spool_dict:
+ identity = _material_identity_spoolman(spool_dict)
+ rw = spool_dict.get("remaining_weight")
+ if isinstance(rw, (int, float)) and rw >= 0:
+ remaining = float(rw)
+ else:
+ used = spool_dict.get("used_weight")
+ total = (spool_dict.get("filament") or {}).get("weight")
+ if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+ remaining = max(0.0, float(total) - float(used))
else:
internal_result = await db.execute(
select(SpoolAssignment)
@@ -254,6 +417,7 @@ async def compute_deficit_for_queue_item(
if assignment is None or assignment.spool is None:
continue
spool = assignment.spool
+ identity = _material_identity_internal(spool)
label_weight = float(spool.label_weight or 0)
weight_used = float(spool.weight_used or 0)
if label_weight <= 0:
@@ -261,22 +425,130 @@ async def compute_deficit_for_queue_item(
remaining = max(0.0, label_weight - weight_used)
if remaining is None:
- # Spoolman unreachable for this spool — skip rather than block.
- continue
- if remaining >= float(used_grams):
+ # Unable to determine remaining grams — preserve pre-#1762 behaviour
+ # (don't block on undetermined data).
continue
- deficits.append(
- FilamentDeficit(
+ resolved.append(
+ _ReqRow(
slot_id=slot_id,
ams_id=ams_id,
tray_id=tray_id,
+ global_tray_id=global_tray_id,
+ required=float(used_grams),
+ identity=identity,
+ remaining=remaining,
filament_type=str(req.get("type", "")),
- required_grams=float(used_grams),
- remaining_grams=remaining,
+ extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
)
)
+ # ------------------------------------------------------------------ phase 2
+ # When backup is OFF, fall back to today's per-slot accounting (one-line
+ # equivalence of the original loop), so this path is a strict no-op
+ # behaviour-wise vs. the pre-#1762 code.
+ if not backup_on:
+ return [
+ FilamentDeficit(
+ slot_id=row.slot_id,
+ ams_id=row.ams_id,
+ tray_id=row.tray_id,
+ filament_type=row.filament_type,
+ required_grams=row.required,
+ remaining_grams=row.remaining,
+ )
+ for row in resolved
+ if row.remaining is not None and row.remaining < row.required
+ ]
+
+ # ------------------------------------------------------------------ phase 3
+ # Backup ON: build (identity, extruder)-keyed pool and required-sum maps
+ # from EVERY assigned spool on the printer (not just the slots in the
+ # print's mapping). Then emit deficits only when the pool for a slot's
+ # material is too small for the print's total required of that material.
+ pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
+ required_by_key: dict[tuple[str, int], float] = defaultdict(float)
+
+ if spoolman_mode:
+ sm_all = await db.execute(
+ select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == item.printer_id)
+ )
+ from backend.app.services.spoolman import (
+ SpoolmanClientError,
+ SpoolmanNotFoundError,
+ get_spoolman_client,
+ )
+
+ try:
+ client = await get_spoolman_client()
+ except Exception:
+ client = None
+ for sa in sm_all.scalars().all():
+ if client is None:
+ break
+ try:
+ spool_dict = await client.get_spool(sa.spoolman_spool_id)
+ except (SpoolmanNotFoundError, SpoolmanClientError):
+ continue
+ except Exception as e:
+ logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
+ continue
+ if not spool_dict:
+ continue
+ identity = _material_identity_spoolman(spool_dict)
+ rw = spool_dict.get("remaining_weight")
+ r: float | None = None
+ if isinstance(rw, (int, float)) and rw >= 0:
+ r = float(rw)
+ else:
+ used = spool_dict.get("used_weight")
+ total = (spool_dict.get("filament") or {}).get("weight")
+ if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+ r = max(0.0, float(total) - float(used))
+ if r is None:
+ continue
+ extruder = _extruder_side_for_ams(sa.ams_id, ams_extruder_map, is_dual)
+ pool_by_key[(identity, extruder)] += r
+ else:
+ internal_all = await db.execute(
+ select(SpoolAssignment)
+ .options(selectinload(SpoolAssignment.spool))
+ .where(SpoolAssignment.printer_id == item.printer_id)
+ )
+ for assignment in internal_all.scalars().all():
+ spool = assignment.spool
+ if spool is None:
+ continue
+ label_weight = float(spool.label_weight or 0)
+ weight_used = float(spool.weight_used or 0)
+ if label_weight <= 0:
+ continue
+ r = max(0.0, label_weight - weight_used)
+ identity = _material_identity_internal(spool)
+ extruder = _extruder_side_for_ams(assignment.ams_id, ams_extruder_map, is_dual)
+ pool_by_key[(identity, extruder)] += r
+
+ for row in resolved:
+ required_by_key[(row.identity, row.extruder)] += row.required
+
+ deficits: list[FilamentDeficit] = []
+ for row in resolved:
+ key = (row.identity, row.extruder)
+ # Pool insufficient for the print's TOTAL required of this material on
+ # this extruder side → real deficit. The per-slot remaining still gets
+ # surfaced so the UI can point at the slot the user assigned.
+ if pool_by_key[key] < required_by_key[key]:
+ deficits.append(
+ FilamentDeficit(
+ slot_id=row.slot_id,
+ ams_id=row.ams_id,
+ tray_id=row.tray_id,
+ filament_type=row.filament_type,
+ required_grams=row.required,
+ remaining_grams=row.remaining,
+ )
+ )
+
return deficits
diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py
index e237b55ee..c5ea2f7f4 100644
--- a/backend/app/services/print_scheduler.py
+++ b/backend/app/services/print_scheduler.py
@@ -1981,6 +1981,14 @@ class PrintScheduler:
# manual_start) and "scheduler re-blocked" (this method re-flags it
# on identical spool state) (#1698-followup).
if item.skip_filament_check:
+ # #1762 diagnostic: surface the short-circuit at INFO so a
+ # future "Print Anyway didn't work" report (e.g. issue #1762
+ # comment 3) has actionable evidence in the support bundle
+ # without needing DEBUG enabled.
+ logger.info(
+ "Queue item %s honouring user's Print Anyway acknowledgement — skipping deficit check",
+ item.id,
+ )
return False
try:
diff --git a/backend/tests/unit/services/test_filament_deficit.py b/backend/tests/unit/services/test_filament_deficit.py
index 3cdb52106..51945785a 100644
--- a/backend/tests/unit/services/test_filament_deficit.py
+++ b/backend/tests/unit/services/test_filament_deficit.py
@@ -63,12 +63,20 @@ async def _setup_archive_3mf(db_session, tmp_path: Path, filaments: list[dict])
return archive
-async def _spool(db_session, *, label_weight: int, weight_used: float, color: str = "#000000") -> Spool:
+async def _spool(
+ db_session,
+ *,
+ label_weight: int,
+ weight_used: float,
+ color: str = "#000000",
+ slicer_filament: str | None = None,
+) -> Spool:
spool = Spool(
material="PLA",
label_weight=label_weight,
weight_used=weight_used,
rgba=color,
+ slicer_filament=slicer_filament,
)
db_session.add(spool)
await db_session.commit()
@@ -265,3 +273,349 @@ class TestFilamentDeficit:
assert [d.slot_id for d in deficit] == [2]
assert deficit[0].remaining_grams == 50.0
assert deficit[0].required_grams == 80.0
+
+
+class TestFilamentDeficitBackupAware:
+ """#1762 — when AMS Filament Backup is ON, pool remaining grams across
+ same-material spools on the printer (within the same extruder side on
+ dual-nozzle models) before declaring a slot deficit.
+
+ Reporter scenario: PLA Basic in AMS-1 slot 1 with 10 g left, same PLA
+ Basic in AMS-2 slot 1 with 500 g left. Today's per-slot accounting
+ blocks the print because slot 1 of AMS-1 is short. With backup ON,
+ firmware switches mid-print, so the deficit shouldn't fire.
+ """
+
+ @staticmethod
+ def _patch_status(
+ *,
+ printer_id: int,
+ backup_on: bool,
+ ams_extruder_map: dict | None = None,
+ model: str | None = None,
+ ):
+ """Patch ``printer_manager.get_status`` + ``get_model`` for the test."""
+ from types import SimpleNamespace
+ from unittest.mock import patch as _patch
+
+ fake_state = SimpleNamespace(
+ ams_filament_backup=backup_on if backup_on is not None else None,
+ ams_extruder_map=ams_extruder_map or {},
+ )
+
+ return [
+ _patch(
+ "backend.app.services.printer_manager.printer_manager.get_status",
+ lambda pid: fake_state if pid == printer_id else None,
+ ),
+ _patch(
+ "backend.app.services.printer_manager.printer_manager.get_model",
+ lambda pid: model if pid == printer_id else None,
+ ),
+ ]
+
+ @pytest.mark.asyncio
+ async def test_backup_on_pool_covers_short_slot(self, db_session, printer_factory, tmp_path):
+ """The reporter scenario: assigned slot is short, but the same
+ material on a peer slot covers the print. With backup ON, no deficit."""
+ printer = await printer_factory(model="X1C")
+ archive = await _setup_archive_3mf(
+ db_session,
+ tmp_path,
+ [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+ )
+ # Mapped slot: 10 g remaining, same Bambu preset as peer.
+ short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
+ # Peer slot on AMS-2: same preset, 500 g remaining.
+ peer = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
+ await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+ await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
+ item = await _queue_item(
+ db_session,
+ printer_id=printer.id,
+ archive=archive,
+ ams_mapping=[0],
+ )
+
+ patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+ with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+ for p in patches:
+ p.start()
+ try:
+ deficit = await compute_deficit_for_queue_item(db_session, item)
+ finally:
+ for p in patches:
+ p.stop()
+
+ # Pool (10 + 500 = 510 g) covers the 200 g print → no deficit.
+ assert deficit == []
+
+ @pytest.mark.asyncio
+ async def test_backup_on_pool_insufficient_emits_deficit(self, db_session, printer_factory, tmp_path):
+ """Backup ON but the same-material pool across all slots is still
+ too small for the print → deficit emitted (real shortfall)."""
+ printer = await printer_factory(model="X1C")
+ archive = await _setup_archive_3mf(
+ db_session,
+ tmp_path,
+ [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "1500.0"}],
+ )
+ a = await _spool(db_session, label_weight=1000, weight_used=900.0, slicer_filament="GFA00") # 100g
+ b = await _spool(db_session, label_weight=1000, weight_used=700.0, slicer_filament="GFA00") # 300g
+ await _assign(db_session, printer_id=printer.id, spool_id=a.id, ams_id=0, tray_id=0)
+ await _assign(db_session, printer_id=printer.id, spool_id=b.id, ams_id=1, tray_id=0)
+ item = await _queue_item(
+ db_session,
+ printer_id=printer.id,
+ archive=archive,
+ ams_mapping=[0],
+ )
+
+ patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+ with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+ for p in patches:
+ p.start()
+ try:
+ deficit = await compute_deficit_for_queue_item(db_session, item)
+ finally:
+ for p in patches:
+ p.stop()
+
+ # Pool 400 g < required 1500 g → deficit fires.
+ assert len(deficit) == 1
+ assert deficit[0].slot_id == 1
+
+ @pytest.mark.asyncio
+ async def test_backup_on_different_materials_no_pool(self, db_session, printer_factory, tmp_path):
+ """Backup ON, but the peer slot holds a DIFFERENT material — pool
+ doesn't include it, deficit fires for the original short slot."""
+ printer = await printer_factory(model="X1C")
+ archive = await _setup_archive_3mf(
+ db_session,
+ tmp_path,
+ [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "200.0"}],
+ )
+ # Assigned slot: PLA White preset GFA01, 10 g.
+ short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#FFFFFF", slicer_filament="GFA01")
+ # Peer: PLA Black, different preset (GFA00) — NOT a backup peer under the strict rule.
+ peer = await _spool(db_session, label_weight=1000, weight_used=500.0, color="#000000", slicer_filament="GFA00")
+ await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+ await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
+ item = await _queue_item(
+ db_session,
+ printer_id=printer.id,
+ archive=archive,
+ ams_mapping=[0],
+ )
+
+ patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+ with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+ for p in patches:
+ p.start()
+ try:
+ deficit = await compute_deficit_for_queue_item(db_session, item)
+ finally:
+ for p in patches:
+ p.stop()
+
+ # Pool for white = 10 g, required = 200 g → deficit.
+ assert len(deficit) == 1
+ assert deficit[0].slot_id == 1
+ assert deficit[0].remaining_grams == 10.0
+
+ @pytest.mark.asyncio
+ async def test_backup_off_falls_back_to_per_slot_accounting(self, db_session, printer_factory, tmp_path):
+ """When backup is OFF the new code path must be a strict no-op vs.
+ the pre-#1762 per-slot accounting. Identical inputs to the
+ ``pool_covers_short_slot`` case but with backup OFF — deficit fires."""
+ printer = await printer_factory(model="X1C")
+ archive = await _setup_archive_3mf(
+ db_session,
+ tmp_path,
+ [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+ )
+ short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
+ peer = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
+ await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+ await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
+ item = await _queue_item(
+ db_session,
+ printer_id=printer.id,
+ archive=archive,
+ ams_mapping=[0],
+ )
+
+ patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="X1C")
+ with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+ for p in patches:
+ p.start()
+ try:
+ deficit = await compute_deficit_for_queue_item(db_session, item)
+ finally:
+ for p in patches:
+ p.stop()
+
+ # Backup OFF → per-slot accounting → slot 1 has 10 g, needs 200 g.
+ assert len(deficit) == 1
+ assert deficit[0].remaining_grams == 10.0
+
+ @pytest.mark.asyncio
+ async def test_backup_on_dual_extruder_scopes_pool_per_side(self, db_session, printer_factory, tmp_path):
+ """Dual-extruder printer (H2D): peer slot on the OPPOSITE extruder
+ does NOT count toward the pool — firmware can't cross. Deficit fires."""
+ printer = await printer_factory(model="O1D") # H2D internal code
+ archive = await _setup_archive_3mf(
+ db_session,
+ tmp_path,
+ [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+ )
+ short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
+ peer_other_side = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
+ # AMS 0 is on extruder 0 (right). AMS 1 is on extruder 1 (left).
+ await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+ await _assign(db_session, printer_id=printer.id, spool_id=peer_other_side.id, ams_id=1, tray_id=0)
+ item = await _queue_item(
+ db_session,
+ printer_id=printer.id,
+ archive=archive,
+ ams_mapping=[0],
+ )
+
+ patches = TestFilamentDeficitBackupAware._patch_status(
+ printer_id=printer.id,
+ backup_on=True,
+ ams_extruder_map={"0": 0, "1": 1},
+ model="O1D",
+ )
+ with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+ for p in patches:
+ p.start()
+ try:
+ deficit = await compute_deficit_for_queue_item(db_session, item)
+ finally:
+ for p in patches:
+ p.stop()
+
+ # Pool for extruder 0 = 10 g (peer on extruder 1 is unreachable) <
+ # required 200 g → deficit.
+ assert len(deficit) == 1
+ assert deficit[0].slot_id == 1
+
+ @pytest.mark.asyncio
+ async def test_backup_on_no_preset_never_pairs(self, db_session, printer_factory, tmp_path):
+ """Strict rule: two user-tagged spools with no slicer_filament preset
+ must NEVER pair, even when material + colour match. Mirrors Bambu
+ firmware: the backup decision relies on the Bambu Lab preset ID, so
+ generic spools without one can't be trusted to switch."""
+ printer = await printer_factory(model="X1C")
+ archive = await _setup_archive_3mf(
+ db_session,
+ tmp_path,
+ [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+ )
+ # Both spools: material PLA, colour black, NO preset → unique keys.
+ short = await _spool(db_session, label_weight=1000, weight_used=990.0)
+ peer_no_preset = await _spool(db_session, label_weight=1000, weight_used=500.0)
+ await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+ await _assign(db_session, printer_id=printer.id, spool_id=peer_no_preset.id, ams_id=1, tray_id=0)
+ item = await _queue_item(
+ db_session,
+ printer_id=printer.id,
+ archive=archive,
+ ams_mapping=[0],
+ )
+
+ patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+ with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+ for p in patches:
+ p.start()
+ try:
+ deficit = await compute_deficit_for_queue_item(db_session, item)
+ finally:
+ for p in patches:
+ p.stop()
+
+ # No preset means no pool — slot 1's 10 g vs 200 g required → deficit.
+ assert len(deficit) == 1
+ assert deficit[0].slot_id == 1
+ assert deficit[0].remaining_grams == 10.0
+
+ @pytest.mark.asyncio
+ async def test_backup_on_same_preset_different_colors_does_not_pair(self, db_session, printer_factory, tmp_path):
+ """STRICT colour rule: two spools sharing the same Bambu preset ID
+ but DIFFERENT colours must NOT pool. Three PETG HF spools in
+ different colours can't back each other up — the firmware would
+ switch material correctly but the print would change colour
+ mid-run. Pool is per-(preset, colour)."""
+ printer = await printer_factory(model="X1C")
+ archive = await _setup_archive_3mf(
+ db_session,
+ tmp_path,
+ [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+ )
+ # Assigned slot: PLA Basic + GFA00 + BLACK, only 10 g left.
+ short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#000000", slicer_filament="GFA00")
+ # Peer slot: same GFA00 profile but WHITE — must not pool.
+ peer_diff_color = await _spool(
+ db_session, label_weight=1000, weight_used=500.0, color="#FFFFFF", slicer_filament="GFA00"
+ )
+ await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+ await _assign(db_session, printer_id=printer.id, spool_id=peer_diff_color.id, ams_id=1, tray_id=0)
+ item = await _queue_item(
+ db_session,
+ printer_id=printer.id,
+ archive=archive,
+ ams_mapping=[0],
+ )
+
+ patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+ with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+ for p in patches:
+ p.start()
+ try:
+ deficit = await compute_deficit_for_queue_item(db_session, item)
+ finally:
+ for p in patches:
+ p.stop()
+
+ # Pool for (GFA00, black) = 10 g; required = 200 g → deficit.
+ assert len(deficit) == 1
+ assert deficit[0].slot_id == 1
+ assert deficit[0].remaining_grams == 10.0
+
+ @pytest.mark.asyncio
+ async def test_backup_on_color_alpha_normalized(self, db_session, printer_factory, tmp_path):
+ """Colour normalisation: 6-char hex matches 8-char hex of the same
+ RGB. ``000000`` and ``000000FF`` should both resolve to BLACK."""
+ printer = await printer_factory(model="X1C")
+ archive = await _setup_archive_3mf(
+ db_session,
+ tmp_path,
+ [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+ )
+ short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#000000", slicer_filament="GFA00")
+ # Same colour but expressed with explicit alpha.
+ peer = await _spool(
+ db_session, label_weight=1000, weight_used=500.0, color="#000000FF", slicer_filament="GFA00"
+ )
+ await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+ await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
+ item = await _queue_item(
+ db_session,
+ printer_id=printer.id,
+ archive=archive,
+ ams_mapping=[0],
+ )
+
+ patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+ with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+ for p in patches:
+ p.start()
+ try:
+ deficit = await compute_deficit_for_queue_item(db_session, item)
+ finally:
+ for p in patches:
+ p.stop()
+
+ # Pool (10 + 500 = 510 g) covers 200 g → no deficit.
+ assert deficit == []
diff --git a/frontend/scripts/check-i18n-parity.mjs b/frontend/scripts/check-i18n-parity.mjs
index 5d2d78e0d..5407ac515 100644
--- a/frontend/scripts/check-i18n-parity.mjs
+++ b/frontend/scripts/check-i18n-parity.mjs
@@ -143,6 +143,8 @@ const DE_COGNATES = [
'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
+ 'AMS Filament Backup', // Bambu Lab product/firmware feature name
+
'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
@@ -171,6 +173,7 @@ const DE_COGNATES = [
// French cognates — many UI labels overlap with English exactly.
const FR_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
+ 'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
@@ -209,6 +212,7 @@ const FR_COGNATES = [
// Italian cognates.
const IT_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
+ 'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Email', // common loanword in Italian, used verbatim in UI labels
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
@@ -249,6 +253,7 @@ const JA_COGNATES = [
// Portuguese (BR) cognates.
const PT_BR_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
+ 'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@@ -316,6 +321,7 @@ const KO_COGNATES = [
// Spanish cognates — words/phrases that are genuinely identical in Spanish.
const ES_COGNATES = [
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
+ 'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
@@ -335,6 +341,7 @@ const ES_COGNATES = [
const TR_COGNATES = [
'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale
+ 'AMS Filament Backup', // Bambu Lab product/firmware feature name
'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
'N/A', 'email',
'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',
diff --git a/frontend/src/__tests__/components/AmsBackupModal.test.tsx b/frontend/src/__tests__/components/AmsBackupModal.test.tsx
new file mode 100644
index 000000000..ed107d28c
--- /dev/null
+++ b/frontend/src/__tests__/components/AmsBackupModal.test.tsx
@@ -0,0 +1,247 @@
+/**
+ * Render tests for the AMS Filament Backup modal (#1762).
+ *
+ * Modal now renders one SVG ring per backup pair (BambuStudio Auto Refill
+ * style); lone slots are intentionally suppressed.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+
+import { render } from '../utils';
+import { AmsBackupModal } from '../../components/AmsBackupModal';
+
+function makeAmsUnits() {
+ return [
+ {
+ id: 0,
+ tray: [
+ { id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000000', tray_info_idx: 'GFA00' },
+ { id: 1, tray_type: 'PETG', tray_sub_brands: 'PETG HF', tray_color: '#0000FF', tray_info_idx: 'GFG99' },
+ ],
+ },
+ {
+ id: 1,
+ tray: [
+ { id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000000', tray_info_idx: 'GFA00' },
+ ],
+ },
+ ];
+}
+
+describe('AmsBackupModal', () => {
+ it('returns null when isOpen=false', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('[data-testid="ams-backup-modal"]')).toBeNull();
+ });
+
+ it('renders a backup ring for each pair and OMITS lone slots', async () => {
+ render(
+ ,
+ );
+
+ // PLA Basic — pair: rendered in centre of its ring
+ expect(await screen.findByText('PLA Basic')).toBeInTheDocument();
+ // PETG HF — lone, must NOT appear (no longer listed)
+ expect(screen.queryByText('PETG HF')).not.toBeInTheDocument();
+ });
+
+ it('closes on Escape keypress while open', async () => {
+ const onClose = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('does NOT fire onClose on Escape when closed (listener unmounts)', () => {
+ const onClose = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ rerender(
+ ,
+ );
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('toggle reflects the ON state and fires onToggle(false) when clicked', async () => {
+ const onToggle = vi.fn();
+ render(
+ ,
+ );
+
+ const toggle = await screen.findByRole('switch');
+ expect(toggle).toHaveAttribute('aria-checked', 'true');
+ fireEvent.click(toggle);
+ expect(onToggle).toHaveBeenCalledWith(false);
+ });
+
+ it('toggle is disabled when state is unknown (A1 family)', async () => {
+ render(
+ ,
+ );
+ const toggle = await screen.findByRole('switch');
+ expect(toggle).toBeDisabled();
+ expect(screen.getByText(/Unsupported/i)).toBeInTheDocument();
+ });
+
+ it('toggle is disabled when the user lacks printers:control', async () => {
+ render(
+ ,
+ );
+ const toggle = await screen.findByRole('switch');
+ expect(toggle).toBeDisabled();
+ });
+
+ it('shows a no-pairs empty state when AMS has no backup pair', async () => {
+ render(
+ ,
+ );
+ expect(await screen.findByText(/No backup pairs/i)).toBeInTheDocument();
+ });
+
+ it('on dual-extruder with distinct map values, renders R / L badges on each ring', async () => {
+ render(
+ ,
+ );
+
+ // Both rings should be present (PLA Basic and PETG HF in the centres).
+ expect(await screen.findByText('PLA Basic')).toBeInTheDocument();
+ expect(screen.getByText('PETG HF')).toBeInTheDocument();
+ // Both extruder badges visible.
+ expect(screen.getByText('R')).toBeInTheDocument();
+ expect(screen.getByText('L')).toBeInTheDocument();
+ });
+
+ it('collapses to single section (no R/L badges) when isDualNozzle=true but map has one distinct value', async () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText('R')).not.toBeInTheDocument();
+ expect(screen.queryByText('L')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/__tests__/pages/PrintersPageBackupGroups.test.ts b/frontend/src/__tests__/pages/PrintersPageBackupGroups.test.ts
new file mode 100644
index 000000000..f1546deff
--- /dev/null
+++ b/frontend/src/__tests__/pages/PrintersPageBackupGroups.test.ts
@@ -0,0 +1,223 @@
+/**
+ * Tests for #1762 — `computeBackupGroups` strict identity rule.
+ *
+ * Slots pair ONLY when they share the same Bambu preset ID
+ * (`tray_info_idx`). User-tagged spools without a preset never pair.
+ * Empty slots are skipped; non-empty slots without a peer come back as
+ * 1-member entries so the modal can list them as "Slots without a peer".
+ */
+import { describe, it, expect } from 'vitest';
+
+import { computeBackupGroups } from '../../utils/amsHelpers';
+
+function ams(id: number, tray: Array<{
+ tray_type?: string | null;
+ tray_sub_brands?: string | null;
+ tray_color?: string | null;
+ tray_info_idx?: string | null;
+}>) {
+ return {
+ id,
+ tray: tray.map((t, i) => ({
+ id: i,
+ tray_type: t.tray_type ?? null,
+ tray_sub_brands: t.tray_sub_brands ?? null,
+ tray_color: t.tray_color ?? null,
+ tray_info_idx: t.tray_info_idx ?? null,
+ })),
+ };
+}
+
+describe('computeBackupGroups', () => {
+ it('returns empty list for missing/empty AMS input', () => {
+ expect(computeBackupGroups(undefined, {}, false)).toEqual([]);
+ expect(computeBackupGroups([], {}, false)).toEqual([]);
+ });
+
+ it('skips empty slots entirely', () => {
+ const groups = computeBackupGroups(
+ [ams(0, [
+ { tray_type: null, tray_color: null, tray_info_idx: null },
+ { tray_type: null, tray_color: null, tray_info_idx: null },
+ ])],
+ {},
+ false,
+ );
+ expect(groups).toEqual([]);
+ });
+
+ it('groups two slots in different AMS units holding the same preset', () => {
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ],
+ {},
+ false,
+ );
+
+ expect(groups).toHaveLength(1);
+ expect(groups[0].presetId).toBe('GFA00');
+ expect(groups[0].members.map((m) => m.globalTrayId)).toEqual([0, 4]);
+ });
+
+ it('STRICT rule: two slots without a preset never pair, even with matching material+colour', () => {
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#FF0000' }]),
+ ams(1, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#FF0000' }]),
+ ],
+ {},
+ false,
+ );
+
+ // Two lone slots — no pair.
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.members.length === 1)).toBe(true);
+ expect(groups.every((g) => g.presetId === null)).toBe(true);
+ });
+
+ it('does NOT group slots with different presets even if same material', () => {
+ const groups = computeBackupGroups(
+ [ams(0, [
+ { tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' },
+ { tray_type: 'PLA', tray_color: '#FFFFFF', tray_info_idx: 'GFA01' },
+ ])],
+ {},
+ false,
+ );
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.members.length === 1)).toBe(true);
+ });
+
+ it('returns lone slots alongside pairs in the same list', () => {
+ const groups = computeBackupGroups(
+ [
+ ams(0, [
+ { tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' },
+ { tray_type: 'PETG', tray_color: '#0000FF', tray_info_idx: 'GFG99' },
+ ]),
+ ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ],
+ {},
+ false,
+ );
+ // 1 pair + 1 lone, pair first by sort order.
+ expect(groups).toHaveLength(2);
+ expect(groups[0].members).toHaveLength(2);
+ expect(groups[1].members).toHaveLength(1);
+ expect(groups[1].displayName).toContain('PETG');
+ });
+
+ it('on dual-extruder printers, scopes pairs per extruder side', () => {
+ // ams 0 = right (0), ams 1 = left (1). Same preset on different sides:
+ // each comes back as a 1-member entry.
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ],
+ { '0': 0, '1': 1 },
+ true,
+ );
+ expect(groups).toHaveLength(2);
+ expect(groups.every((g) => g.members.length === 1)).toBe(true);
+ expect(groups[0].extruder).toBe(0);
+ expect(groups[1].extruder).toBe(1);
+ });
+
+ it('on dual-extruder printers, pairs slots on the same extruder side', () => {
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ams(2, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ],
+ { '0': 0, '1': 1, '2': 0 },
+ true,
+ );
+
+ // Right-side pair (AMS 0 + 2), left-side lone (AMS 1).
+ const rightPair = groups.find((g) => g.extruder === 0 && g.members.length === 2);
+ expect(rightPair).toBeDefined();
+ expect(rightPair!.members.map((m) => m.globalTrayId).sort((a, b) => a - b)).toEqual([0, 8]);
+ const leftLone = groups.find((g) => g.extruder === 1);
+ expect(leftLone).toBeDefined();
+ expect(leftLone!.members).toHaveLength(1);
+ });
+
+ it('handles AMS-HT (single-tray, id >= 128) via getGlobalTrayId — pairs with regular AMS slot', () => {
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ams(128, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ],
+ {},
+ false,
+ );
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members.map((m) => m.globalTrayId).sort((a, b) => a - b)).toEqual([0, 128]);
+ });
+
+ it('STRICT colour rule: same preset, different colours do NOT pair', () => {
+ // Reporter screenshot scenario — three PETG HF slots all sharing the
+ // same Bambu profile ID (e.g. GFG99) but in three different colours
+ // cannot back each other up; the firmware would correctly swap PETG HF
+ // but the print would change colour mid-run.
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PETG', tray_color: '#000000', tray_info_idx: 'GFG99' }]),
+ ams(1, [{ tray_type: 'PETG', tray_color: '#FF0000', tray_info_idx: 'GFG99' }]),
+ ams(2, [{ tray_type: 'PETG', tray_color: '#00FF00', tray_info_idx: 'GFG99' }]),
+ ],
+ {},
+ false,
+ );
+ // Three lone slots — no pair.
+ expect(groups).toHaveLength(3);
+ expect(groups.every((g) => g.members.length === 1)).toBe(true);
+ });
+
+ it('colour normalisation: 6-char and 8-char hex of the same RGB pair correctly', () => {
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }]),
+ ams(1, [{ tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00' }]),
+ ],
+ {},
+ false,
+ );
+ expect(groups).toHaveLength(1);
+ expect(groups[0].members).toHaveLength(2);
+ });
+
+ it('defensively dedupes duplicate ams.id entries (first wins)', () => {
+ // Observed in the wild: status.ams sometimes contains the same ams.id
+ // twice (VP-aggregated switch printers, MQTT partial-update edge cases).
+ // The modal must NOT render the same slot label with conflicting
+ // materials — first occurrence wins, second is dropped.
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PETG', tray_color: '#000000', tray_info_idx: 'GFG99' }]),
+ ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+ ],
+ {},
+ false,
+ );
+ expect(groups).toHaveLength(1);
+ expect(groups[0].displayName).toContain('PETG');
+ });
+
+ it('preserves display name + tray colour from the first slot for the modal swatch', () => {
+ const groups = computeBackupGroups(
+ [
+ ams(0, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#1A1A1A', tray_info_idx: 'GFA00' }]),
+ ams(1, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#1A1A1A', tray_info_idx: 'GFA00' }]),
+ ],
+ {},
+ false,
+ );
+ expect(groups[0].displayName).toBe('PLA Basic');
+ expect(groups[0].trayColor).toBe('#1A1A1A');
+ });
+});
diff --git a/frontend/src/components/AmsBackupModal.tsx b/frontend/src/components/AmsBackupModal.tsx
new file mode 100644
index 000000000..a70923ac8
--- /dev/null
+++ b/frontend/src/components/AmsBackupModal.tsx
@@ -0,0 +1,334 @@
+/**
+ * #1762 — AMS Filament Backup status modal.
+ *
+ * Opens from the AmsBackupBadge click. Shows the global toggle and a
+ * BambuStudio-style ring graphic per backup pair — each ring represents
+ * the rotation order the firmware will follow when the active slot runs
+ * out.
+ *
+ * On dual-extruder printers, each ring carries a small "R" / "L" badge
+ * because the firmware can't cross extruders even with the global backup
+ * bit set.
+ *
+ * Theme-aware via CSS variables, matching AMSHistoryModal — adapts to
+ * every background variant the user has picked.
+ */
+import { useEffect } from 'react';
+import { X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import { Toggle } from './Toggle';
+import {
+ computeBackupGroups,
+ normalizeColor,
+ type AmsUnitLike,
+ type BackupGroup,
+} from '../utils/amsHelpers';
+
+interface AmsBackupModalProps {
+ isOpen: boolean;
+ state: boolean | null;
+ amsUnits: AmsUnitLike[] | undefined;
+ amsExtruderMap: Record | undefined;
+ isDualNozzle: boolean;
+ canToggle: boolean;
+ pending: boolean;
+ onToggle: (next: boolean) => void;
+ onClose: () => void;
+}
+
+/**
+ * Compact slot label like "A·3" / "HT·1" — the ring is small, every char
+ * counts toward readability.
+ */
+function formatSlotLabel(amsId: number, slotIdx: number, totalTraysOnUnit: number): string {
+ const isHt = totalTraysOnUnit === 1 || amsId >= 128;
+ const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
+ const letter = String.fromCharCode(65 + normalizedId);
+ return isHt ? `HT·${slotIdx + 1}` : `${letter}·${slotIdx + 1}`;
+}
+
+/** Pick a readable text colour for a given filament hex. */
+function pickContrastTextColor(rgbaHex: string | null | undefined): string {
+ const s = (rgbaHex || '').replace('#', '').slice(0, 6);
+ if (s.length !== 6) return '#FFFFFF';
+ const r = parseInt(s.slice(0, 2), 16);
+ const g = parseInt(s.slice(2, 4), 16);
+ const b = parseInt(s.slice(4, 6), 16);
+ if ([r, g, b].some(Number.isNaN)) return '#FFFFFF';
+ const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
+ return luma > 0.55 ? '#1A1A1A' : '#FFFFFF';
+}
+
+function BackupRing({
+ group,
+ trayCountByAms,
+ innerBg,
+ textPrimary,
+ textSecondary,
+ showExtruderBadge,
+ extruderLabel,
+}: {
+ group: BackupGroup;
+ trayCountByAms: Map;
+ innerBg: string;
+ textPrimary: string;
+ textSecondary: string;
+ showExtruderBadge: boolean;
+ extruderLabel: string;
+}) {
+ const filamentHex = normalizeColor(group.trayColor || undefined);
+ const ringTextColor = pickContrastTextColor(group.trayColor);
+ // The pill background that sits behind each slot label — keeps text legible
+ // regardless of the filament fill colour.
+ const labelPillBg = ringTextColor === '#FFFFFF' ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.7)';
+ const n = group.members.length;
+
+ // Geometry: -100..100 viewport. Outer ring 92, inner cutout 56.
+ // Slot labels sit on the colour band at radius 76.
+ const labelRadius = 76;
+
+ return (
+