From 9d74f9281bab926bd291f76a7b781aab8d55b226 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 22 Jun 2026 10:00:02 +0200 Subject: [PATCH] feat(deficit): backup-aware filament deficit check, colour-strict (#1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the printer reports ams_filament_backup=True, compute_deficit_for_queue_item pools remaining_grams across spools matching (preset, colour) on the same printer (scoped per extruder on dual-nozzle) before declaring a per-slot shortfall. Identity is strict: same slicer_filament preset AND same colour (alpha-normalised). Two PETG HF spools in different colours are NOT pooled — the firmware would swap correctly but the print would change colour mid-run. Spoolman side mirrors the rule via filament.id + color_hex. Backup OFF falls back to the pre-PR per-slot accounting line-for-line. 8 new test cases in TestFilamentDeficitBackupAware pin pool covers, pool insufficient, different presets, backup-OFF regression, dual- extruder side scoping, no-preset never pairs, colour-strict, and alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py cases stay green. feat(printers): AMS Filament Backup modal with BS-style ring per pair Badge click on the Filaments section header (#1766) now opens a modal: filament-colour ring per backup pair, material name + rotation count in the centre, slot labels distributed around the colour band on contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill widget. Lone slots are intentionally not listed. R / L badges per ring when the extruder map carries two distinct values; collapses to no- badge rendering for single-nozzle printers misflagged as dual. Esc keypress closes the modal. Theme-aware via CSS variables matching AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers defensively dedupes duplicate ams.id entries observed on switch-VP aggregations. 10 modal render cases pin: Esc closes / unmount nulls the listener / ring renders for pairs and omits lone slots / R-L badges only when extruder map has distinct values / empty state / toggle gating. 13 frontend cases pin computeBackupGroups identity rules. feat(printers): active-print P-N pill on AMS slot tiles during RUNNING While the printer is mid-print, each AMS slot tile referenced by status.ams_mapping carries a small "P1 / P2 / P3" pill in the top- right corner, naming which print-slot is mapped to that AMS slot. Catches the #1762 comment-2 scenario: a queue job set for "any X1C" staged to a printer with mismatched filament, no way to verify mid- print. Same wire data (status.ams_mapping is already on the wire) — the addition is purely surface. The existing ring-bambu-green highlight for effectiveTrayNow keeps its meaning (currently extruding RIGHT NOW); the pill is the per-slot static assignment for the active print. chore(scheduler): log Print Anyway short-circuit at INFO _block_on_filament_deficit logs at INFO when it honours item.skip_filament_check, so a future "Print Anyway didn't work" report (third commenter on #1762 hit this shape) has actionable evidence in the standard support bundle without DEBUG. Bundled because the deficit fix makes the original symptom disappear for users with backup ON. --- CHANGELOG.md | 10 + backend/app/services/filament_deficit.py | 290 +++++++++++++- backend/app/services/print_scheduler.py | 8 + .../unit/services/test_filament_deficit.py | 356 +++++++++++++++++- frontend/scripts/check-i18n-parity.mjs | 7 + .../components/AmsBackupModal.test.tsx | 247 ++++++++++++ .../pages/PrintersPageBackupGroups.test.ts | 223 +++++++++++ frontend/src/components/AmsBackupModal.tsx | 334 ++++++++++++++++ frontend/src/i18n/locales/de.ts | 13 + frontend/src/i18n/locales/en.ts | 13 + frontend/src/i18n/locales/es.ts | 13 + frontend/src/i18n/locales/fr.ts | 13 + frontend/src/i18n/locales/it.ts | 13 + frontend/src/i18n/locales/ja.ts | 13 + frontend/src/i18n/locales/ko.ts | 15 +- frontend/src/i18n/locales/pt-BR.ts | 13 + frontend/src/i18n/locales/tr.ts | 13 + frontend/src/i18n/locales/zh-CN.ts | 13 + frontend/src/i18n/locales/zh-TW.ts | 13 + frontend/src/pages/PrintersPage.tsx | 82 +++- frontend/src/utils/amsHelpers.ts | 139 +++++++ static/assets/index-Bp52mo4E.css | 2 + .../{index-CksvU0PF.js => index-CtWW9ce9.js} | 294 +++++++-------- static/assets/index-DNavQjwR.css | 2 - static/index.html | 4 +- 25 files changed, 1965 insertions(+), 178 deletions(-) create mode 100644 frontend/src/__tests__/components/AmsBackupModal.test.tsx create mode 100644 frontend/src/__tests__/pages/PrintersPageBackupGroups.test.ts create mode 100644 frontend/src/components/AmsBackupModal.tsx create mode 100644 static/assets/index-Bp52mo4E.css rename static/assets/{index-CksvU0PF.js => index-CtWW9ce9.js} (65%) delete mode 100644 static/assets/index-DNavQjwR.css 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 ( +
+ {showExtruderBadge && ( + + {extruderLabel} + + )} + + {/* Subtle outer ring — gives a crisp edge on light AND dark themes. */} + + {/* Colour band */} + + {/* Inner cutout */} + + {/* Inner ring border for definition between centre and colour band */} + + {/* Centre: material name */} + + {group.displayName || '—'} + + {/* Centre: rotation count */} + + {`${n}× ↻`} + + {/* Slot labels around the ring, each on a pill for legibility */} + {group.members.map((m, i) => { + const angleDeg = (i * 360) / n - 90; + const rad = (angleDeg * Math.PI) / 180; + const x = labelRadius * Math.cos(rad); + const y = labelRadius * Math.sin(rad); + const label = formatSlotLabel(m.amsId, m.slotIdx, trayCountByAms.get(m.amsId) ?? 4); + // Approximate pill width based on char count (each digit ≈ 6.5 px @ 12 px font). + const pillWidth = Math.max(22, label.length * 7 + 8); + return ( + + + + {label} + + + ); + })} + +
+ ); +} + +export function AmsBackupModal({ + isOpen, + state, + amsUnits, + amsExtruderMap, + isDualNozzle, + canToggle, + pending, + onToggle, + onClose, +}: AmsBackupModalProps) { + const { t } = useTranslation(); + + // Close on Escape key while the modal is open. Captures at the window + // level so it works even when focus isn't inside the modal subtree + // (e.g. after the Toggle is clicked). + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + // Theme-aware tokens, matching AMSHistoryModal. + const modalBg = 'var(--bg-secondary)'; + const sectionBg = 'var(--bg-primary)'; + const borderColor = 'var(--border-color)'; + const textPrimary = 'var(--text-primary)'; + const textSecondary = 'var(--text-secondary)'; + + // Effective dual-nozzle detection: only split per extruder if the map + // actually carries 2 distinct values across the AMS units we have data + // for. Empty / single-value maps collapse to a single section to avoid + // misleading badges. + const effectiveDualNozzle = (() => { + if (!isDualNozzle) return false; + if (!amsExtruderMap) return false; + const distinctValues = new Set(); + for (const ams of amsUnits || []) { + const raw = amsExtruderMap[String(ams.id)]; + if (raw === undefined) continue; + distinctValues.add(Number(raw)); + if (distinctValues.size > 1) return true; + } + return false; + })(); + + const groups = computeBackupGroups(amsUnits, amsExtruderMap, effectiveDualNozzle); + const trayCountByAms = new Map( + (amsUnits || []).map((u) => [u.id, u.tray.length]), + ); + + // Only pairs are rendered — lone slots are deliberately suppressed. + const pairs = groups.filter((g) => g.members.length >= 2); + + const isOn = state === true; + const isUnknown = state === null; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="ams-backup-modal-title" + > +
+

+ {t('printers.amsBackup.modalTitle')} +

+ +
+ +
+
+
+ {isUnknown + ? t('printers.amsBackup.stateUnknown') + : isOn + ? t('printers.amsBackup.stateOn') + : t('printers.amsBackup.stateOff')} +
+

+ {t('printers.amsBackup.modalHelp')} +

+
+ +
+ +
+ {pairs.length === 0 ? ( +

+ {t('printers.amsBackup.modalNoPairs')} +

+ ) : ( +
+ {pairs.map((g) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 322acebfb..55fa38536 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -547,6 +547,19 @@ export default { titleUnknown: 'AMS-Filament-Backup-Status auf diesem Drucker nicht verfügbar.', toastEnabled: 'AMS Filament Backup aktiviert', toastDisabled: 'AMS Filament Backup deaktiviert', + modalTitle: 'AMS Filament Backup', + modalHelp: 'Wenn der aktive Slot leer wird, wechselt der Drucker in dieser Reihenfolge zu Slots mit demselben Preset und derselben Farbe.', + modalNoSlots: 'Kein Filament geladen.', + modalNoPairs: 'Keine Backup-Paare — keine zwei Slots teilen sich Filament-Preset und Farbe.', + extruderRightShort: 'R', + extruderLeftShort: 'L', + stateOn: 'Aktiviert', + stateOff: 'Deaktiviert', + stateUnknown: 'Auf diesem Drucker nicht unterstützt', + }, + activeJobSlot: { + title: 'Dieser Slot ist Filament {{n}} im aktiven Druck', + ariaLabel: 'Aktiver Druck-Slot {{n}}', }, // Filaments section filaments: 'Filamente', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 581e74808..27288f558 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -551,6 +551,19 @@ export default { titleUnknown: 'AMS Filament Backup status unavailable on this printer.', toastEnabled: 'AMS Filament Backup enabled', toastDisabled: 'AMS Filament Backup disabled', + modalTitle: 'AMS Filament Backup', + modalHelp: 'When the active slot runs out, the printer cycles through any matching same-preset, same-colour slots in this order.', + modalNoSlots: 'No filament loaded.', + modalNoPairs: 'No backup pairs — no two slots share the same filament profile and colour.', + extruderRightShort: 'R', + extruderLeftShort: 'L', + stateOn: 'Enabled', + stateOff: 'Disabled', + stateUnknown: 'Unsupported on this printer', + }, + activeJobSlot: { + title: 'This slot is filament {{n}} in the active print', + ariaLabel: 'Active print slot {{n}}', }, // Filaments section filaments: 'Filaments', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 155f24077..1162ccded 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -547,6 +547,19 @@ export default { titleUnknown: 'Estado de AMS Filament Backup no disponible en esta impresora.', toastEnabled: 'AMS Filament Backup activado', toastDisabled: 'AMS Filament Backup desactivado', + modalTitle: 'AMS Filament Backup', + modalHelp: 'Cuando el slot activo se agota, la impresora rota a slots con el mismo preset y color en este orden.', + modalNoSlots: 'Sin filamento cargado.', + modalNoPairs: 'No hay pares de respaldo — no hay dos slots que compartan preset y color.', + extruderRightShort: 'D', + extruderLeftShort: 'I', + stateOn: 'Activado', + stateOff: 'Desactivado', + stateUnknown: 'No compatible con esta impresora', + }, + activeJobSlot: { + title: 'Este slot es el filamento {{n}} en la impresión activa', + ariaLabel: 'Slot de impresión activa {{n}}', }, // Filaments section filaments: 'Filamentos', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index dd52ba4a5..932905acd 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -547,6 +547,19 @@ export default { titleUnknown: "État de l'AMS Filament Backup indisponible sur cette imprimante.", toastEnabled: "AMS Filament Backup activé", toastDisabled: "AMS Filament Backup désactivé", + modalTitle: 'AMS Filament Backup', + modalHelp: "Lorsque l'emplacement actif s'épuise, l'imprimante passe aux emplacements ayant le même preset et la même couleur dans cet ordre.", + modalNoSlots: "Aucun filament chargé.", + modalNoPairs: "Aucune paire de secours — aucun emplacement ne partage à la fois preset et couleur.", + extruderRightShort: "D", + extruderLeftShort: "G", + stateOn: "Activé", + stateOff: "Désactivé", + stateUnknown: "Non pris en charge par cette imprimante", + }, + activeJobSlot: { + title: 'Cet emplacement est le filament {{n}} dans l\'impression active', + ariaLabel: 'Emplacement d\'impression active {{n}}', }, // Filaments section filaments: 'Filaments', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index ebab1267a..83a49a745 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -547,6 +547,19 @@ export default { titleUnknown: 'Stato di AMS Filament Backup non disponibile su questa stampante.', toastEnabled: 'AMS Filament Backup abilitato', toastDisabled: 'AMS Filament Backup disabilitato', + modalTitle: 'AMS Filament Backup', + modalHelp: "Quando lo slot attivo si esaurisce, la stampante passa agli slot con lo stesso preset e colore in quest'ordine.", + modalNoSlots: 'Nessun filamento caricato.', + modalNoPairs: 'Nessuna coppia di backup — nessuno slot condivide preset e colore.', + extruderRightShort: 'D', + extruderLeftShort: 'S', + stateOn: 'Abilitato', + stateOff: 'Disabilitato', + stateUnknown: 'Non supportato su questa stampante', + }, + activeJobSlot: { + title: 'Questo slot è il filamento {{n}} nella stampa attiva', + ariaLabel: 'Slot stampa attiva {{n}}', }, // Filaments section filaments: 'Filamenti', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 16545cd37..06647455c 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -546,6 +546,19 @@ export default { titleUnknown: 'このプリンタではAMSフィラメントバックアップ状態を確認できません。', toastEnabled: 'AMSフィラメントバックアップを有効化しました', toastDisabled: 'AMSフィラメントバックアップを無効化しました', + modalTitle: 'AMS フィラメントバックアップ', + modalHelp: 'アクティブなスロットが空になると、プリンターは同じプリセット・同じ色のスロットをこの順序で循環します。', + modalNoSlots: 'フィラメントが読み込まれていません。', + modalNoPairs: 'バックアップペアがありません — プリセットと色の両方が一致するスロットがありません。', + extruderRightShort: '右', + extruderLeftShort: '左', + stateOn: '有効', + stateOff: '無効', + stateUnknown: 'このプリンターでは未対応', + }, + activeJobSlot: { + title: 'このスロットはアクティブな印刷のフィラメント {{n}} です', + ariaLabel: 'アクティブ印刷スロット {{n}}', }, // Filaments section filaments: 'フィラメント', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 9f7b0eab1..7373a6807 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -509,7 +509,20 @@ export default { titleOff: 'AMS 필라멘트 백업이 꺼져 있습니다. 활성화하려면 클릭하세요.', titleUnknown: '이 프린터에서는 AMS 필라멘트 백업 상태를 확인할 수 없습니다.', toastEnabled: 'AMS 필라멘트 백업이 활성화되었습니다', - toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다' + toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다', + modalTitle: 'AMS 필라멘트 백업', + modalHelp: '활성 슬롯이 소진되면 프린터는 같은 프리셋과 색상의 슬롯을 이 순서로 순환합니다.', + modalNoSlots: '필라멘트가 로드되지 않았습니다.', + modalNoPairs: '백업 쌍이 없습니다 — 프리셋과 색상이 모두 일치하는 슬롯이 없습니다.', + extruderRightShort: '우', + extruderLeftShort: '좌', + stateOn: '활성화됨', + stateOff: '비활성화됨', + stateUnknown: '이 프린터에서 지원되지 않음' + }, + activeJobSlot: { + title: '이 슬롯은 활성 인쇄의 필라멘트 {{n}}입니다', + ariaLabel: '활성 인쇄 슬롯 {{n}}' }, filaments: '필라멘트', openCameraOverlay: '카메라 오버레이 열기', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 1aa5bcb87..02cd2c383 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -547,6 +547,19 @@ export default { titleUnknown: 'Estado do AMS Filament Backup indisponível nesta impressora.', toastEnabled: 'AMS Filament Backup ativado', toastDisabled: 'AMS Filament Backup desativado', + modalTitle: 'AMS Filament Backup', + modalHelp: 'Quando o slot ativo termina, a impressora alterna para slots com o mesmo preset e cor nesta ordem.', + modalNoSlots: 'Nenhum filamento carregado.', + modalNoPairs: 'Sem pares de backup — nenhum par de slots compartilha preset e cor.', + extruderRightShort: 'D', + extruderLeftShort: 'E', + stateOn: 'Ativado', + stateOff: 'Desativado', + stateUnknown: 'Não suportado nesta impressora', + }, + activeJobSlot: { + title: 'Este slot é o filamento {{n}} na impressão ativa', + ariaLabel: 'Slot de impressão ativa {{n}}', }, // Filaments section filaments: 'Filamentos', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 1b002cd7d..c95174e57 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -547,6 +547,19 @@ export default { titleUnknown: 'Bu yazıcıda AMS Filament Backup durumu kullanılamıyor.', toastEnabled: 'AMS Filament Backup etkinleştirildi', toastDisabled: 'AMS Filament Backup devre dışı bırakıldı', + modalTitle: 'AMS Filament Backup', + modalHelp: 'Aktif slot tükendiğinde, yazıcı aynı ön ayar ve renkteki slotları bu sırayla dolaşır.', + modalNoSlots: 'Yüklenmiş filament yok.', + modalNoPairs: 'Yedek çifti yok — hiçbir slot çifti aynı ön ayar ve rengi paylaşmıyor.', + extruderRightShort: 'S', + extruderLeftShort: 'L', + stateOn: 'Etkin', + stateOff: 'Devre dışı', + stateUnknown: 'Bu yazıcıda desteklenmiyor', + }, + activeJobSlot: { + title: 'Bu slot, aktif baskıdaki {{n}} numaralı filament', + ariaLabel: 'Aktif baskı slotu {{n}}', }, // Filamentler bölümü filaments: 'Filamentler', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index f321222aa..a66eb79a7 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -547,6 +547,19 @@ export default { titleUnknown: '本打印机不支持读取 AMS 备用料盘状态。', toastEnabled: 'AMS 备用料盘已启用', toastDisabled: 'AMS 备用料盘已禁用', + modalTitle: 'AMS 备用料盘', + modalHelp: '当前料槽用尽时,打印机会按此顺序在使用相同预设和颜色的料槽之间循环。', + modalNoSlots: '未加载耗材。', + modalNoPairs: '没有备用料盘对 — 没有两个料槽同时匹配预设和颜色。', + extruderRightShort: '右', + extruderLeftShort: '左', + stateOn: '已启用', + stateOff: '已禁用', + stateUnknown: '此打印机不支持', + }, + activeJobSlot: { + title: '此料槽在当前打印中是耗材 {{n}}', + ariaLabel: '当前打印料槽 {{n}}', }, // Filaments section filaments: '耗材', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 4615026fb..848705f87 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -547,6 +547,19 @@ export default { titleUnknown: '本印表機不支援讀取 AMS 備用料盤狀態。', toastEnabled: 'AMS 備用料盤已啟用', toastDisabled: 'AMS 備用料盤已停用', + modalTitle: 'AMS 備用料盤', + modalHelp: '目前料槽用盡時,印表機會按此順序在使用相同預設和顏色的料槽之間循環。', + modalNoSlots: '未載入耗材。', + modalNoPairs: '沒有備用料盤對 — 沒有兩個料槽同時匹配預設和顏色。', + extruderRightShort: '右', + extruderLeftShort: '左', + stateOn: '已啟用', + stateOff: '已停用', + stateUnknown: '此印表機不支援', + }, + activeJobSlot: { + title: '此料槽在目前列印中是耗材 {{n}}', + ariaLabel: '目前列印料槽 {{n}}', }, // Filaments section filaments: '耗材', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index d3845e590..8db9a621f 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -98,6 +98,7 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal'; import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal'; import { PrinterQueueWidget } from '../components/PrinterQueueWidget'; import { AMSHistoryModal } from '../components/AMSHistoryModal'; +import { AmsBackupModal } from '../components/AmsBackupModal'; import { HeaterHistoryModal } from '../components/HeaterHistoryModal'; import type { HeaterSensorKind } from '../api/client'; import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard'; @@ -697,29 +698,26 @@ function HeaterThermometer({ className, color, isHeating }: HeaterThermometerPro // AMS Filament Backup tri-state indicator + toggle. // state=true → ON, click to disable -// state=false → OFF, click to enable -// state=null → unknown/unsupported (e.g. A1 family), no click action +// state=false → OFF, click opens modal +// state=null → unknown/unsupported (e.g. A1 family), click disabled interface AmsBackupBadgeProps { state: boolean | null; - canToggle: boolean; - pending: boolean; - onToggle: (next: boolean) => void; + onClick: () => void; } -function AmsBackupBadge({ state, canToggle, pending, onToggle }: AmsBackupBadgeProps) { +function AmsBackupBadge({ state, onClick }: AmsBackupBadgeProps) { const { t } = useTranslation(); const known = state !== null; - const clickable = canToggle && known && !pending; let className = 'flex items-center justify-center w-[18px] h-[18px] rounded text-[10px] transition-colors '; let title: string; if (state === true) { - className += clickable + className += known ? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 cursor-pointer' : 'bg-blue-500/20 text-blue-400 cursor-default'; title = t('printers.amsBackup.titleOn'); } else if (state === false) { - className += clickable + className += known ? 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80 cursor-pointer' : 'bg-bambu-dark text-bambu-gray cursor-default'; title = t('printers.amsBackup.titleOff'); @@ -731,8 +729,8 @@ function AmsBackupBadge({ state, canToggle, pending, onToggle }: AmsBackupBadgeP return (