From b1cb26f6ee1b42dd48b338026cbc88eec69501e8 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 20 Jun 2026 12:07:20 +0200 Subject: [PATCH] feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766) Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup status/control surface, and the #1766 fix that depends on it. Added -- AMS Filament Backup status + control - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup on every push_status. Verified against OrcaSlicer source (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state (None = A1 family / pre-cfg push) preserves today's behaviour. - Hold-timer guard (3 s) prevents stale frames from flickering the badge back to the printer's old cfg after a user-initiated toggle. - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client method calling _set_print_option("auto_switch_filament", enabled). - GET /printers/{id}/inventory-remain endpoint exposes the same map the dispatcher uses (internal and Spoolman modes both work uniformly). - Small icon badge in the printer card's "Filaments" section header (placement reads as printer-wide because the cfg bit is printer-wide, not per-AMS). Click to toggle, success toast. - 5 i18n keys x 11 locales for the badge UI. Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest to False when status.ams_filament_backup is False; log the skip. - New effectivePreferLowest(setting, backup) helper applied at every frontend sort entry point: single-printer PrintModal, multi-printer hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping standalone editor (the last had NO preferLowest awareness at all before this change). - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier key exactly, including the banding tie-break (regular AMS < AMS-HT < external) so the client-side pre-compute matches the dispatch-time pick. An earlier draft used a flat `amsId * 4 + trayId` priority which gave external slots (ams_id = -1) a NEGATIVE priority -- caught in code review before commit. - Settings -> Filament -> "Prefer lowest remaining filament" gets an explanatory note about the printer-side AMS Backup dependency, with i18n key in all 11 locales. --- CHANGELOG.md | 4 + backend/app/api/routes/printers.py | 51 ++++ backend/app/main.py | 2 +- backend/app/schemas/printer.py | 4 + backend/app/services/bambu_mqtt.py | 59 ++++ backend/app/services/print_scheduler.py | 9 + backend/app/services/printer_manager.py | 4 + .../tests/unit/services/test_bambu_mqtt.py | 56 ++++ .../tests/unit/test_bambu_mqtt_cfg_parse.py | 58 ++++ .../unit/test_inventory_remain_endpoint.py | 86 ++++++ .../test_printer_manager_status_broadcast.py | 1 + .../unit/test_printer_offline_notification.py | 1 + .../tests/unit/test_scheduler_backup_gate.py | 96 +++++++ .../components/PrinterSelector.test.ts | 37 +++ .../hooks/useFilamentMapping.test.ts | 143 ++++++++++ frontend/src/api/client.ts | 19 ++ .../components/PrintModal/FilamentMapping.tsx | 31 +- .../components/PrintModal/PrinterSelector.tsx | 9 +- frontend/src/components/PrintModal/index.tsx | 49 +++- frontend/src/hooks/useFilamentMapping.ts | 56 ++-- .../hooks/useMultiPrinterFilamentMapping.ts | 52 +++- frontend/src/i18n/locales/de.ts | 8 + frontend/src/i18n/locales/en.ts | 9 + frontend/src/i18n/locales/es.ts | 8 + frontend/src/i18n/locales/fr.ts | 8 + frontend/src/i18n/locales/it.ts | 8 + frontend/src/i18n/locales/ja.ts | 8 + frontend/src/i18n/locales/ko.ts | 8 + frontend/src/i18n/locales/pt-BR.ts | 8 + frontend/src/i18n/locales/tr.ts | 8 + frontend/src/i18n/locales/zh-CN.ts | 8 + frontend/src/i18n/locales/zh-TW.ts | 8 + frontend/src/pages/PrintersPage.tsx | 69 +++++ frontend/src/pages/SettingsPage.tsx | 3 + frontend/src/utils/amsHelpers.ts | 79 ++++- .../{index-pp8r-Sv1.js => index-B3zC_9bK.js} | 270 +++++++++--------- static/assets/index-CEe69URA.css | 2 + static/assets/index-iyQgdQ5p.css | 2 - static/index.html | 4 +- 39 files changed, 1155 insertions(+), 190 deletions(-) create mode 100644 backend/tests/unit/test_bambu_mqtt_cfg_parse.py create mode 100644 backend/tests/unit/test_inventory_remain_endpoint.py create mode 100644 backend/tests/unit/test_scheduler_backup_gate.py rename static/assets/{index-pp8r-Sv1.js => index-B3zC_9bK.js} (68%) create mode 100644 static/assets/index-CEe69URA.css delete mode 100644 static/assets/index-iyQgdQ5p.css diff --git a/CHANGELOG.md b/CHANGELOG.md index b58a3ab82..0e3a6e089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,11 @@ All notable changes to Bambuddy will be documented in this file. - **Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by @phieb)** — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. **Default off so upgraders don't silently start injecting**: existing `gcode_snippets` installs keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unless `gcode_snippets` are configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." **DB column:** new `virtual_printers.gcode_injection BOOLEAN DEFAULT FALSE` with a branched `is_sqlite()` migration (SQLite `DEFAULT 0` / Postgres `DEFAULT FALSE`) matching the `queue_force_color_match` / `tailscale_disabled` precedent. **Multi-plate stamping:** the flag is set on every plate's `PrintQueueItem` inside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. **Live-toggle correctness:** the `_sync_from_db_locked` change detector now compares `instance.gcode_injection != vp.gcode_injection`, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicated `test_sync_from_db_restarts_on_gcode_injection_toggle`. **UI:** new toggle on `VirtualPrinterCard.tsx` (queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standard `updateMutation` save-on-click + toast on success, plus the `pendingAction='gcodeInjection'` opacity dim during the round-trip. **PrintModal hardening:** when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A new `useEffect` clears the stale `gcodeInjection` state if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. **Diagnostics:** the resolved start/end snippets (with `{placeholder}` substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. **i18n:** new `virtualPrinter.gcodeInjection.title` + `description` keys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. **Tests:** 2 new unit cases in `test_virtual_printer.py` (queue items opt in / out based on the VP flag), 2 new integration cases in `test_virtual_printer_api.py` (create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to `_make_db_vp` so the change-detector test fixture carries an explicit `False` rather than relying on `MagicMock` truthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean; `npm run build` clean. +- **AMS Filament Backup status + control on the printer card** — New per-printer surface that mirrors BambuStudio's "AMS Filament Backup" checkbox (the per-AMS auto-switch to a second matching spool when one runs out). Until now Bambuddy had no read or write access to the printer-side backup state; the only way to change it was via the slicer or the printer's touchscreen, and Bambuddy's "Prefer lowest remaining filament" preference was ignorant of it (see the linked Fixed entry for #1766 — the two ship together). **Backend — parse the state.** New tri-state `PrinterState.ams_filament_backup: bool | None` populated from bit 18 of the top-level `print.cfg` hex string on every push_status (`bambu_mqtt.py::_process_message` ~line 1037). New module-level helper `parse_ams_filament_backup_from_cfg()` returns `None` on absent / non-hex / non-string input so old-protocol families (A1 / A1 Mini, which emit no `cfg`) preserve today's behaviour — the tri-state default applies the dispatcher's sort, never coerces to OFF, so A1 users see zero regression. Verified against OrcaSlicer source (`DeviceManager.cpp:4961` `SetAutoRefillEnabled(get_flag_bits(cfg, 18))`) and a live H2D ON/OFF capture during this work — the cfg flips exactly between `C0340FC219` (bit 18 set, ON) and `C0340BC219` (bit 18 clear, OFF), only the fifth nibble changing. **Backend — toggle.** New `POST /printers/{id}/ams-backup?enabled=` route gated on `Permission.PRINTERS_CONTROL` calls `client.set_ams_filament_backup(enabled)` which routes through `_set_print_option("auto_switch_filament", enabled)`. The MQTT payload shape `{"print": {"command": "print_option", "auto_switch_filament": , "sequence_id": "20000"}}` was verified by capturing BambuStudio's own command on the request topic with a temporary outbound diagnostic logger — single field at a time, never bundled with other `print_option` flags, so we never clobber other state. Optimistic local state update lives inside `_set_print_option` immediately after `_client.publish(...)`. **Hold-timer guard** (`_xcam_hold_start["print_option_auto_switch_filament"]`, 3 s window, mirrors the existing xcam pattern for spaghetti / first-layer detector settings): when the user just toggled via Bambuddy's badge, the next 1-2 push_status frames may still carry the printer's PRE-toggle cfg before the firmware reflects the change — without this gate the badge would flicker ON→OFF→ON on every toggle. The hold fires only when Bambuddy itself initiated the change; Studio-side or printer-display toggles propagate immediately. **Backend — inventory-remain endpoint.** New `GET /printers/{id}/inventory-remain` route exposes the same `Map` the dispatcher uses (via the existing `_build_inventory_remain_overrides` helper), so PrintModal's client-side "Prefer Lowest Remaining Filament" sort can apply the same two-tier ordering the backend would on dispatch. Internal AND Spoolman modes both work uniformly via the existing helper's mode branch — external / VT slots excluded, negative grams clamped to `max(0.0, label - used)`. JSON-keyed-as-string convention so the wire format is clean; client coerces back to Number on receive. Permission: `Permission.PRINTERS_READ` (same as reading printer status). **REST + WS response surface.** `printer_state_to_dict` and the `PrinterStatusResponse` Pydantic schema both extended with the new field; the printer's REST `/printers/{id}` response carries `ams_filament_backup`. `state.ams_filament_backup` added to the `status_key` dedup tuple in `main.py:1101` so backup toggles trigger an immediate WS broadcast and clients see live state changes whether the toggle came from Bambuddy, BambuStudio, or the printer's touchscreen. **Frontend — printer card badge.** Small icon button in the "Filaments" section header on each printer card (`PrintersPage.tsx`), placed beside the section label so the printer-wide nature reads correctly (the cfg bit is one per printer, not per AMS unit — the original draft put it per-AMS row, which would have duplicated the same state on multi-AMS printers and looked confusing). Three states: ON = blue circular-arrow icon (`Repeat` from lucide-react) on `bg-blue-500/20`; OFF = dim icon on `bg-bambu-dark`; unknown (A1 family / no cfg yet) = "?" character on dim background, click disabled. Click on a known state toggles via the new endpoint, with optimistic update and success toast (`AMS Filament Backup enabled/disabled`). The mutation invalidates BOTH `'printerStatus'` (camelCase) and `'printer-status'` (kebab-case) cache keys — the codebase has both conventions in active use (`useFilamentMapping`-related hooks use kebab, everything else uses camelCase), so only hitting one would leave PrintModal showing stale backup state if the user toggled from the printer card while the modal was open. **i18n.** 5 new keys in the `printers.amsBackup.*` namespace (`titleOn`, `titleOff`, `titleUnknown`, `toastEnabled`, `toastDisabled`) translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. **Tests.** 12 new backend cases — `test_bambu_mqtt_cfg_parse.py` (parser × 13: real H2D ON/OFF captures, X1C short hex, lowercase, isolated bit-18 set / clear, every malformed shape returns None safely — note: 1 case is a parametrized invalid-input set of 7 sub-cases so the test file shows 13 reported cases) and `test_bambu_mqtt.py::TestAmsFilamentBackupHoldTimer` (× 3: stale push during hold ignored, push after hold applies, same-value push during hold no-op). Two PrinterState SimpleNamespace stubs in `test_printer_offline_notification.py` and `test_printer_manager_status_broadcast.py` extended with `ams_filament_backup=None` to match the new `status_key` field; full pytest confirms no other stub needed updating. **What this does NOT do.** Cover A1 / A1 Mini: those models emit no `cfg` field in push_status so the badge shows `?` and the dispatcher's sort applies as before. Once we identify the A1-specific field (waiting on a future Discord owner with a clean ON/OFF capture) we'll populate it via a model-specific path; until then the tri-state default keeps zero regression. Affect downstream consumers of PrinterState: `mqtt_relay`, webhook routes, and Home Assistant integration enumerate fields explicitly, so adding `ams_filament_backup` doesn't change what they emit. Full backend pytest 6217/6217; full frontend vitest 2170/2170; ruff clean; `npm run build` clean; ESLint clean. + ### Fixed +- **"Prefer lowest remaining filament" did not actually pick the lowest spool, and could pick a near-empty spool on printers with AMS Filament Backup disabled (#1766, reported by @biduleman)** — Reporter (P1S) had `prefer_lowest_filament=true` with two identical-brand identical-color spools in the AMS, one with less remaining filament than the other; Bambuddy still picked the first slot every time. Root-cause investigation found TWO separate bugs feeding the one report. **(1) The frontend sort never saw inventory grams.** The backend has a two-tier sort (`_prefer_lowest_sort_key` at `print_scheduler.py:1161`) that puts inventory-bound spools in tier 0 (sorted by `label_weight - weight_used` for internal mode or Spoolman's `remaining_weight` for Spoolman mode) and MQTT-only spools in tier 1 (sorted by the printer's `remain%` field) — but it only runs on queue items dispatched without a pre-set `ams_mapping`. The PrintModal "Print Now" / "Add to Queue" flow pre-computes the mapping client-side and submits it, so the backend uses it as-is and the two-tier sort never fires for this path. The frontend's pre-compute (`useFilamentMapping.ts::computeAmsMapping` + `useFilamentMapping`, `useMultiPrinterFilamentMapping.ts::computeMappingWithOverrides` + `computeMatchDetails`, `amsHelpers.ts::autoMatchFilament`) only sorted by `remain%` and had no notion of inventory grams. For two RFID Bambu spools both reporting `remain=100` (freshly inserted, no recent prints) the sort tied at value 100, the slot-position tie-break favoured the lower slot, and the first slot always won — exactly what the reporter saw. **(2) The sort had no notion of whether the printer could actually USE a near-empty spool.** Without AMS Filament Backup enabled on the printer, the firmware will not switch to a second spool when the picked one runs out — so sorting toward the lowest left prints at risk of running dry mid-job. The user-side preference was completely ignorant of the printer-side capability that makes it safe. **Fix — dispatch-time backend gate.** `_compute_ams_mapping_for_printer` at `print_scheduler.py:867` coerces `prefer_lowest=False` when `status.ams_filament_backup is False`, logs `[prefer-lowest] skipped (AMS Backup OFF on printer %s)` so the decision is visible in support bundles without enabling DEBUG. Tri-state default: `None` (unknown / A1 family) applies the sort, preserving today's behaviour. **Fix — banding-equivalent two-tier frontend sort.** New exported `preferLowestSortKey(f, inventoryByTrayId)` in `amsHelpers.ts` mirrors the backend banding exactly — inventory-bound spools sort to tier 0, MQTT-only to tier 1, with backend-matching slot tie-break (`amsId * 4 + trayId` for regular AMS, `1000 + (amsId - 128) * 4 + trayId` for AMS-HT, `10_000` for external / VT so external always sorts LAST regardless of negative raw `ams_id`). All seven frontend sort sites switched to it: `autoMatchFilament` + 2 sites in `useFilamentMapping.ts::computeAmsMapping` (top-level + nested for non-unique tray_info_idx) + 2 sites in `useFilamentMapping.ts::useFilamentMapping` (the hook variant) + 2 sites in `useMultiPrinterFilamentMapping.ts` (`computeMappingWithOverrides`, `computeMatchDetails`) + `autoConfigurePrinter`. Hook signatures grew an optional `inventoryByTrayId?: Map` param — undefined preserves pre-#1766 behaviour for any caller that hasn't wired it in. The banding tie-break alignment is load-bearing on its own: an earlier draft used `amsId * 4 + trayId` for all slots, which gives `ams_id = -1` (external) a NEGATIVE priority that would have beaten AMS slot 0 — caught during a second-round code audit and fixed before commit. **Fix — frontend backup gate.** New exported `effectivePreferLowest(setting, amsFilamentBackup)` mirrors the backend gate rule (`!setting → false; backup === false → false; otherwise true`). PrintModal computes it for the single-printer flow at `index.tsx:380`; `useMultiPrinterFilamentMapping` computes it per-printer inside the `printerResults.map` (different printers in the same dispatch can have different backup states, so a global flag would be wrong); PrinterSelector's `InlineMappingEditor` and `FilamentMapping.tsx`'s standalone editor (the per-AMS slot dropdown) both wired through. The standalone editor previously had NO `preferLowest` awareness at all — its auto-suggestion could disagree with what would actually be dispatched. Closed in this change. **Inventory map — single source of truth.** New `GET /printers/{id}/inventory-remain` endpoint (see Added entry above) exposes the same `_build_inventory_remain_overrides` result the dispatcher uses, so PrintModal and `FilamentMapping.tsx` get the same `Map` the backend would compute. Internal AND Spoolman modes both work uniformly via the existing scheduler helper's branch — external / VT slots excluded, negative grams clamped. Frontend fetches per selected printer via `useQueries` keyed on `'printer-inventory-remain'`, 30 s staleTime, no fetch for unselected printers. An earlier attempt derived the map client-side from `/inventory/assignments` directly — that endpoint only reads the internal-mode `SpoolAssignment` table, so Spoolman users would have silently fallen back to remain%-only sorting. The dedicated endpoint closes that gap. **Settings → Filaments tooltip.** Explanatory note added under the "Prefer lowest remaining filament" toggle description: "Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out." Save behaviour unchanged (existing debounced-save fires the "Settings saved" toast). **i18n.** New key `settings.preferLowestFilamentBackupNote` translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5198 leaves per locale, no English fallback. **Tests.** 7 new backend cases — `test_scheduler_backup_gate.py` (tri-state gate × 4: backup OFF coerces, backup ON applies, None preserves today's behaviour, user setting OFF short-circuits regardless of backup) and `test_inventory_remain_endpoint.py` (× 3: no status returns empty map, normal serialisation with string keys, no bindings returns empty). 8 new frontend cases — `useFilamentMapping.test.ts` (`computeAmsMapping` inventory × 3 + `effectivePreferLowest` gate × 5 + slot-priority banding regression × 1) and `PrinterSelector.test.ts` (`autoMatchFilament` inventory × 3). Existing 56 + 27 cases still green — backwards-compat preserved by optional new params. Full backend `pytest -n 30` 6217/6217 in 74s; full frontend `vitest` 2170/2170 in 29s; ruff clean; `npm run build` clean; eslint clean; i18n parity green. + - **H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — printer auto-picked the last matching nozzle instead (#1780, reported by @mkoreen)** — The reporter (H2C with the rack-swap "dual nozzle variant" model code O1C2; 7 nozzles registered across two extruders: R1/R2 high-flow 0.4, R3/R4 standard 0.4, plus three other diameters) noticed that picking a specific nozzle in Bambu Studio (e.g. "use R1") had no effect — the H2C would consistently load R2 for HF prints and R4 for standard prints. Root-cause traced from the printer's reported `device.nozzle.info[]` state + the Bambu Studio source: BambuStudio's `project_file` MQTT command for O1C2 carries two extra fields — `nozzle_mapping` (a `list[int]` of per-filament physical nozzle position IDs, populated from a prior `get_auto_nozzle_mapping` round-trip with the firmware) and `nozzles_info` (a `list[dict]` of per-extruder rack metadata: `id`/`type`/`flowSize`/`diameter`). The VP intake at `virtual_printer/manager.py:564-574` only captured five fields out of the slicer's project_file dict (bed_leveling / flow_cali / vibration_cali / layer_inspect / timelapse) and dropped everything else, including these two. Without `nozzle_mapping` on the dispatched project_file, the H2C firmware fell back to its auto-pick rule ("any nozzle matching diameter + flow class") and deterministically landed on the last matching slot in the rack — which is why R1 selections always became R2 and R3 selections always became R4. **Fix:** carry both fields through the full intake → queue → dispatch path. `_add_to_print_queue` reads `nozzle_mapping` + `nozzles_info` out of the captured slicer opts, normalises a stringified-JSON or already-parsed shape to the same canonical JSON-string representation, and stamps both on the PrintQueueItem inside the multi-plate loop (so a multi-plate "Send All" preserves the nozzle pick across plates, mirroring `gcode_injection` / `filament_overrides` per-plate stamping from #1697 / #1188). New nullable TEXT columns `nozzle_mapping` / `nozzles_info` on `print_queue` — non-branched ALTER (same as `ams_mapping` / `filament_overrides` precedent at `database.py:944/955`). The dispatcher reads the JSON strings off the queue item, parses them back to list/dict, and includes them on the published `project_file` command as parsed JSON values (not strings — the wire shape matches BS's, same convention as `ams_mapping` / `ams_mapping2`). Dual-nozzle gate at `bambu_mqtt.py::start_print()` keeps the fields off single-nozzle dispatches as defense-in-depth (`is_dual_nozzle` runtime flag already established by `device.extruder.info[]` len ≥ 2). **Fail-open on malformed JSON:** an unparseable column value logs a WARNING and omits the field — firmware then runs the same auto-pick path that was the pre-fix behaviour, never a worse one. **No model gate elsewhere:** every other model omits these fields from its project_file, so the pass-through is a transparent no-op on X1C / P1S / A1 / H2D / X2D. **API surface:** `PrintQueueItemResponse` parses both fields back to `list[int]` / `list[dict]` so any future "edit print → nozzle" UI can read+round-trip them; `PrintQueueItemUpdate` accepts them and the route handler serialises to JSON for storage (same shape as `ams_mapping`). **Tests:** 3 new cases in `test_virtual_printer.py::TestVirtualPrinterInstance` (capture round-trip, NULL-on-omitted-fields, per-plate stamping on multi-plate) and 6 new cases in `test_bambu_mqtt.py::TestStartPrintNozzleMappingDispatch` (dual-nozzle injection both fields, single-nozzle no-emit even when set, dual-nozzle no-fields no-op, partial-only mapping passthrough, malformed JSON logs + dispatch continues, empty-string treated as absent). 1 line update in `test_printer_manager.py::test_start_print_calls_client` to add the two new kwargs to the `assert_called_once_with` matcher. Full backend `pytest -n 30` 6176/6176 in 86.67s; ruff clean; `npm run build` clean; vitest 2158/2158; i18n parity green. **Scope:** O1C2 (the H2C dual-nozzle-rack variant) is the only Bambu model with a rack-swap mechanism where the firmware can choose between multiple physical nozzles per side, so the observable fix lands there. H2D / X2D dual-extruder routing was never affected — those carry filament-to-extruder mapping through `ams_mapping2` (ams_id 254/255), which Bambuddy already forwards correctly. No DB migration on Postgres-only side; no permission change, no i18n keys, no frontend changes (a "pick a different nozzle from queue/archives" UI is reasonable follow-up scope but isn't required to close this bug — the slicer's pick now rides through, which is the reporter's primary expected behaviour). - **Docker installer fails on the default `/opt/bambuddy` path with "Permission denied" (#1774, reported by @jmoore-skild)** — `install/docker-install.sh::create_install_dir` (line 252) ran `mkdir -p "$INSTALL_PATH"` without sudo while `DEFAULT_INSTALL_PATH="/opt/bambuddy"` (line 32) — root-owned on every Linux distro. `set -e` at line 20 then aborted the whole run before docker compose could ever pull the image. Anyone following the documented `curl … | bash` flow as a normal user hit this immediately. The native installer at `install/install.sh:361` already handles the same situation correctly with `sudo mkdir -p` + `sudo chown`; the Docker variant just never got the same treatment. **Why the fix isn't a default-path change:** the contributor's first instinct was to drop the default to `~/bambuddy` since the Docker installer only writes `docker-compose.yml` + `.env` on the host (real app data lives in named volumes), but `install/update.sh:4` and `install/update_macos.sh:4` both default `INSTALL_DIR` to `/opt/bambuddy`, and `install/README.md:274` documents `INSTALL_DIR=/opt/bambuddy sudo ./update.sh` for the update flow — changing the install default without coordinating the update path would silently break self-service updates for anyone following the docs verbatim. The actual gap is the missing privilege escalation in `create_install_dir`, not the default path. **Fix:** `create_install_dir` now tries `mkdir -p "$INSTALL_PATH" 2>/dev/null` first — the cheap no-sudo path covers `--path ~/bambuddy`, `--path /srv/bambuddy`, and any other writable target — and only falls back to `sudo mkdir -p "$INSTALL_PATH"` + `sudo chown -R "$USER:$USER" "$INSTALL_PATH"` when the unprivileged attempt fails. The chown is load-bearing: without it, the script would later try to write `docker-compose.yml` and `.env` into a root-owned dir as the unprivileged invoking user, kicking off a cascade of EACCES failures further down. Idempotent on re-run (the second `mkdir -p` succeeds against the now-owned dir, no second sudo prompt). `set -e` survives the redirected stderr because the `if !` construct is the documented escape from bash's exit-on-error semantics for an expected-failure check. **Smoke-tested all three branches:** writable target → no sudo prompt fires; idempotent re-run → no second sudo prompt; the failing-mkdir-then-fallback path → `set -e` survives intact. **What this does NOT change:** the default install path stays `/opt/bambuddy` for parity with `install.sh` / `update.sh` / the documented update flow; the Windows mirror at `install/docker-install.ps1` already uses `$env:USERPROFILE\bambuddy` (per-user convention on Windows) and is untouched. No docs change required — `install/README.md` and the wiki Docker page (`bambuddy-wiki/docs/getting-started/docker.md`) both still accurately describe the behaviour. diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index e682475a4..ef1741e09 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -724,6 +724,7 @@ async def get_printer_status( heatbreak_fan_speed=state.heatbreak_fan_speed, firmware_version=state.firmware_version, developer_mode=state.developer_mode if state else None, + ams_filament_backup=state.ams_filament_backup if state else None, awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id), supports_drying=supports_drying(printer.model, state.firmware_version), supports_chamber_heater=supports_chamber_heater(printer.model), @@ -1874,6 +1875,56 @@ async def set_print_option( } +@router.post("/{printer_id}/ams-backup") +async def set_ams_backup( + printer_id: int, + enabled: bool, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): + """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out).""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + client = printer_manager.get_client(printer_id) + if not client or not client.state.connected: + raise HTTPException(400, "Printer not connected") + + success = client.set_ams_filament_backup(enabled) + if not success: + raise HTTPException(500, "Failed to send command to printer") + + return {"success": True, "ams_filament_backup": enabled} + + +@router.get("/{printer_id}/inventory-remain") +async def get_inventory_remain( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), + db: AsyncSession = Depends(get_db), +): + """Per-globalTrayId remaining grams for slots bound to an inventory spool. + + Mirrors `_build_inventory_remain_overrides` server-side so the PrintModal + client can apply the same two-tier "Prefer Lowest Remaining Filament" sort + the dispatcher uses (#1766). Works for both internal inventory and + Spoolman; unbound slots are absent from the map (client falls back to the + printer's MQTT `remain` for those). + """ + from backend.app.services.print_scheduler import PrintScheduler + + state = printer_manager.get_status(printer_id) + if not state: + return {"inventory_remain_g": {}} + + scheduler = PrintScheduler() + loaded = scheduler._build_loaded_filaments(state) + overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded) + return {"inventory_remain_g": {str(k): v for k, v in overrides.items()}} + + # ============================================ # Calibration # ============================================ diff --git a/backend/app/main.py b/backend/app/main.py index ccfa5ace0..b1368ef06 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1098,7 +1098,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState): f"{state.stg_cur}:{bed_target}:{nozzle_target}:" f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:" f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:" - f"{ams_dry_key}:{ams_tray_key}:{state.door_open}" + f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}" ) # MQTT relay - publish status (before dedup check - always publish to MQTT) diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 61a29e136..b74c4825c 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -324,6 +324,10 @@ class PrinterStatus(BaseModel): firmware_version: str | None = None # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown developer_mode: bool | None = None + # AMS Filament Backup ("auto-switch" to a second spool when one runs out). + # True = ON, False = OFF, None = unknown / unsupported (A1 family — protocol field + # not yet identified). UI treats None as "status unavailable", not as a hard disable. + ams_filament_backup: bool | None = None # Queue: printer is awaiting the user to acknowledge the build plate is cleared # after a finished/failed print. Persisted across restarts (#961). awaiting_plate_clear: bool = False diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index ba53b797e..fb494f5cc 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -31,6 +31,23 @@ logger = logging.getLogger(__name__) _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/") +def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None: + """Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value. + + OrcaSlicer reads bit 18 of the hex string via + ``get_flag_bits(cfg, 18)`` (DeviceManager.cpp:4961). Old-protocol families + (A1 / A1 Mini) omit ``cfg`` entirely; this returns ``None`` for any input + that doesn't yield a clean integer so downstream consumers preserve today's + behaviour rather than treating "absent" as "OFF". + """ + if not isinstance(cfg_raw, str) or not cfg_raw: + return None + try: + return bool((int(cfg_raw, 16) >> 18) & 1) + except ValueError: + return None + + def apply_tray_exist_bits( units: list, tray_exist_bits_str: str | int | None, @@ -330,6 +347,11 @@ class PrinterState: # Developer LAN mode: parsed from MQTT "fun" field bit 0x20000000 # True = dev mode ON (no encryption), False = dev mode OFF (encryption required), None = unknown developer_mode: bool | None = None + # AMS Filament Backup: bit 18 of top-level print.cfg hex on new-protocol Bambu + # printers (H/X/P/H2 families). True=ON, False=OFF, None=unknown (e.g. A1 family + # which uses the old protocol path; field not yet found). Consumers must treat + # None as "no opinion" — preserving today's behaviour, NOT as "disabled". + ams_filament_backup: bool | None = None # Stage name mapping from BambuStudio DeviceManager.cpp @@ -1018,6 +1040,33 @@ class BambuMQTTClient: f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}" ) + # AMS Filament Backup state lives in bit 18 of top-level print.cfg on + # new-protocol printers. Verified against OrcaSlicer's + # DeviceManager.cpp:4961 SetAutoRefillEnabled(get_flag_bits(cfg, 18)) + # and live H2D ON/OFF capture 2026-06-20. + # + # Hold-timer guard: when the user just toggled via the badge, the + # next 1-2 push_status frames may still carry the printer's OLD cfg + # for ~3 s before the firmware reflects the change. Without this + # gate the UI would flicker ON→OFF→ON. Same pattern xcam uses. + new_backup = parse_ams_filament_backup_from_cfg(print_data.get("cfg")) + if new_backup is not None and new_backup != self.state.ams_filament_backup: + hold_start = self._xcam_hold_start.get("print_option_auto_switch_filament") + if hold_start is not None and (time.time() - hold_start) <= self._xcam_hold_time: + logger.debug( + "[%s] AMS Filament Backup push ignored (hold active for %.1fs)", + self.serial_number, + time.time() - hold_start, + ) + else: + logger.info( + "[%s] AMS Filament Backup: %s", + self.serial_number, + "ON" if new_backup else "OFF", + ) + self.state.ams_filament_backup = new_backup + self._xcam_hold_start.pop("print_option_auto_switch_filament", None) + # Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it) # device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro) if not self._is_dual_nozzle and "device" in print_data: @@ -3822,9 +3871,19 @@ class BambuMQTTClient: # Update local state immediately if option_name == "auto_recovery": self.state.print_options.auto_recovery_step_loss = enabled + elif option_name == "auto_switch_filament": + self.state.ams_filament_backup = enabled return True + def set_ams_filament_backup(self, enabled: bool) -> bool: + """Toggle AMS Filament Backup (a.k.a. auto-switch / auto-refill). + + Mirrors BambuStudio's "AMS Filament Backup" checkbox. Verified payload + shape from H2D capture 2026-06-20. + """ + return self._set_print_option("auto_switch_filament", enabled) + def start_calibration( self, bed_leveling: bool = False, diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index c19083e13..ac683c0b7 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -860,6 +860,15 @@ class PrintScheduler: # Check if user prefers lowest remaining filament when multiple spools match prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament") + # Gate prefer_lowest on the printer's AMS Filament Backup state (#1766). + # Without backup, the printer will not switch to a second spool when the + # picked one runs out — so sorting toward the lowest leaves the print + # at risk of running dry mid-job. None (unknown / A1 family) preserves + # today's behaviour intentionally. + if prefer_lowest and status.ams_filament_backup is False: + logger.info("[prefer-lowest] skipped (AMS Backup OFF on printer %s)", printer_id) + prefer_lowest = False + # When the preference is on, surface Bambuddy's inventory-side # remaining for each slot that's bound to a tracked spool, so the # sort beats the MQTT-only blind spot (#1508). Skip the lookup diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index da897cd9a..8049dbb48 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -1049,6 +1049,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo "wifi_signal": state.wifi_signal, "wired_network": state.wired_network, "door_open": state.door_open, + # AMS Filament Backup state (auto-switch to second spool). Tri-state: + # True / False / None. None = unknown or unsupported (A1 family). UI + # uses this to drive the small status icon next to the AMS drying icon. + "ams_filament_backup": state.ams_filament_backup, # Calibration stage tracking "stg_cur": state.stg_cur, "stg_cur_name": get_derived_status_name(state, model), diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index 2580efd14..8a9682154 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -5793,3 +5793,59 @@ class TestPrintRunningObservedCallback: "raw_data", "ams_mapping", } + + +class TestAmsFilamentBackupHoldTimer: + """Regression: stale push_status arriving within the hold window after a + toggle command MUST NOT flip ams_filament_backup back to the printer's + old cfg. Same race-guard pattern xcam uses for spaghetti / first-layer + detector settings. + """ + + @pytest.fixture + def mqtt_client(self): + from unittest.mock import MagicMock + + from backend.app.services.bambu_mqtt import BambuMQTTClient + + client = BambuMQTTClient( + ip_address="192.168.1.100", + serial_number="TEST123", + access_code="12345678", + ) + # Pretend we're connected so _set_print_option actually publishes. + client.state.connected = True + client._client = MagicMock() + return client + + def test_cfg_push_with_old_value_is_ignored_during_hold(self, mqtt_client): + # User toggled ON via badge → command sent → state optimistically set. + mqtt_client.set_ams_filament_backup(True) + assert mqtt_client.state.ams_filament_backup is True + + # Within the 3 s hold window, a stale push_status arrives still showing + # the printer's old cfg (bit 18 cleared). The parser must NOT flip our + # optimistic state back to OFF — otherwise the badge flickers ON→OFF→ON. + mqtt_client._process_message({"print": {"cfg": "C0340BC219"}}) # bit18=0 + assert mqtt_client.state.ams_filament_backup is True + + def test_cfg_push_after_hold_expires_overrides_state(self, mqtt_client): + # After the hold window, the printer's real cfg becomes authoritative + # so a genuine slicer-side or display toggle that we did NOT initiate + # propagates correctly. + mqtt_client.set_ams_filament_backup(True) + mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] = time.time() - 10.0 + + mqtt_client._process_message({"print": {"cfg": "C0340BC219"}}) # bit18=0 + assert mqtt_client.state.ams_filament_backup is False + + def test_cfg_push_with_matching_value_during_hold_is_a_noop(self, mqtt_client): + # Same-value push during hold doesn't trigger the change branch at all + # (no state mutation, no log spam, hold timer stays armed). + mqtt_client.set_ams_filament_backup(True) + before_hold = mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] + + mqtt_client._process_message({"print": {"cfg": "C0340FC219"}}) # bit18=1 + assert mqtt_client.state.ams_filament_backup is True + # Hold timer still armed — sub-second push didn't reset it. + assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold diff --git a/backend/tests/unit/test_bambu_mqtt_cfg_parse.py b/backend/tests/unit/test_bambu_mqtt_cfg_parse.py new file mode 100644 index 000000000..ab121fbb9 --- /dev/null +++ b/backend/tests/unit/test_bambu_mqtt_cfg_parse.py @@ -0,0 +1,58 @@ +"""Tests for ``parse_ams_filament_backup_from_cfg`` (#1766 prefer_lowest gate). + +The function extracts bit 18 of Bambu's top-level ``print.cfg`` hex string, +which OrcaSlicer's DeviceManager.cpp:4961 maps to AMS Filament Backup. These +tests pin the bit position + cover the absent / malformed cases A1-family +printers and pre-init pushes produce. +""" + +import pytest + +from backend.app.services.bambu_mqtt import parse_ams_filament_backup_from_cfg + + +class TestParseAmsFilamentBackupFromCfg: + def test_h2d_on_capture(self): + # Captured 2026-06-20 from H2D fw 01.03.00.00 with backup ON. + # Hex "C0340FC219" has bit 18 set (nibble 5 = F = 0b1111). + assert parse_ams_filament_backup_from_cfg("C0340FC219") is True + + def test_h2d_off_capture(self): + # Same printer, backup toggled OFF — only bit 18 flips: + # "C0340BC219" — nibble 5 = B = 0b1011. + assert parse_ams_filament_backup_from_cfg("C0340BC219") is False + + def test_x1c_short_hex_string_on(self): + # X1C cfg in the investigation snapshots is short ("FCA09"). + # Bit 18 of 0xFCA09 = 0b1111110010100001001, bit18 set. + assert parse_ams_filament_backup_from_cfg("FCA09") is True + + def test_lowercase_hex(self): + # Robustness: int(s, 16) accepts both cases; check we don't regress. + assert parse_ams_filament_backup_from_cfg("c0340fc219") is True + + def test_only_bit_18_isolated(self): + # Sanity: a value with ONLY bit 18 set must parse as True. + assert parse_ams_filament_backup_from_cfg(hex(1 << 18)[2:]) is True + + def test_bit_18_clear_but_others_set(self): + # Set every bit EXCEPT 18 — must parse as False. + mask = (~(1 << 18)) & 0xFFFFFFFF + assert parse_ams_filament_backup_from_cfg(hex(mask)[2:]) is False + + @pytest.mark.parametrize( + "value", + [ + None, # field omitted (A1 family old protocol) + "", # empty string + 123, # firmware-emitted int instead of hex string (defensive) + "not_hex", # malformed + "0xZZ", # invalid hex + ["FCA09"], # wrong shape + {"cfg": "FCA09"}, # nested by mistake + ], + ) + def test_invalid_returns_none(self, value): + # None preserves today's behaviour for callers gating on backup state — + # NOT False. Treating absent as OFF would regress A1-family scheduling. + assert parse_ams_filament_backup_from_cfg(value) is None diff --git a/backend/tests/unit/test_inventory_remain_endpoint.py b/backend/tests/unit/test_inventory_remain_endpoint.py new file mode 100644 index 000000000..9d1637a92 --- /dev/null +++ b/backend/tests/unit/test_inventory_remain_endpoint.py @@ -0,0 +1,86 @@ +"""Tests for GET /printers/{id}/inventory-remain (#1766). + +The endpoint exposes the same `_build_inventory_remain_overrides` map the +dispatcher uses so PrintModal's client-side "Prefer Lowest Remaining Filament" +sort agrees with what gets dispatched — closes the gap where Spoolman-mode +users couldn't see inventory grams from the frontend. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.api.routes.printers import get_inventory_remain + + +@pytest.fixture +def db(): + return MagicMock() + + +async def _call_endpoint(db, printer_id=1): + return await get_inventory_remain(printer_id=printer_id, _=None, db=db) + + +class TestGetInventoryRemain: + @pytest.mark.asyncio + async def test_returns_empty_when_printer_has_no_status(self, db): + # Printer disconnected / unknown — endpoint must not error, return {}. + with patch( + "backend.app.services.printer_manager.printer_manager.get_status", + return_value=None, + ): + result = await _call_endpoint(db) + assert result == {"inventory_remain_g": {}} + + @pytest.mark.asyncio + async def test_serialises_globaltrayid_keys_as_strings(self, db): + # JSON requires string keys; client converts back to Number on receive. + # Asserts the key-shape contract the frontend depends on. + state = SimpleNamespace(raw_data={}) + with ( + patch( + "backend.app.services.printer_manager.printer_manager.get_status", + return_value=state, + ), + patch( + "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments", + return_value=[ + {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}, + {"ams_id": 0, "tray_id": 3, "global_tray_id": 3, "is_external": False}, + ], + ), + patch( + "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides", + new=AsyncMock(return_value={0: 950.0, 3: 50.0}), + ), + ): + result = await _call_endpoint(db) + + assert result == {"inventory_remain_g": {"0": 950.0, "3": 50.0}} + + @pytest.mark.asyncio + async def test_returns_empty_dict_when_no_bound_slots(self, db): + # Loaded filaments exist but none are bound to an inventory spool. + # Backend returns {}; route serialises it unchanged. + state = SimpleNamespace(raw_data={}) + with ( + patch( + "backend.app.services.printer_manager.printer_manager.get_status", + return_value=state, + ), + patch( + "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments", + return_value=[ + {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}, + ], + ), + patch( + "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides", + new=AsyncMock(return_value={}), + ), + ): + result = await _call_endpoint(db) + + assert result == {"inventory_remain_g": {}} diff --git a/backend/tests/unit/test_printer_manager_status_broadcast.py b/backend/tests/unit/test_printer_manager_status_broadcast.py index 3d394e570..b5beaf4a1 100644 --- a/backend/tests/unit/test_printer_manager_status_broadcast.py +++ b/backend/tests/unit/test_printer_manager_status_broadcast.py @@ -99,6 +99,7 @@ def _fake_state(**overrides): "tray_now": None, "wifi_signal": None, "wired_network": None, + "ams_filament_backup": None, } base.update(overrides) return SimpleNamespace(**base) diff --git a/backend/tests/unit/test_printer_offline_notification.py b/backend/tests/unit/test_printer_offline_notification.py index cc35cad8e..2936c08c1 100644 --- a/backend/tests/unit/test_printer_offline_notification.py +++ b/backend/tests/unit/test_printer_offline_notification.py @@ -44,6 +44,7 @@ def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace: tray_now=0, door_open=False, subtask_name="", + ams_filament_backup=None, ) diff --git a/backend/tests/unit/test_scheduler_backup_gate.py b/backend/tests/unit/test_scheduler_backup_gate.py new file mode 100644 index 000000000..7c71ab119 --- /dev/null +++ b/backend/tests/unit/test_scheduler_backup_gate.py @@ -0,0 +1,96 @@ +"""Tests for the AMS Filament Backup gate on prefer_lowest sort (#1766). + +The reporter set ``prefer_lowest_filament=True`` but the printer kept picking +the first matching spool. Root cause: without the printer's AMS Filament +Backup enabled, switching to the second spool is impossible — so sorting +toward the lowest leaves the print at risk. The gate coerces prefer_lowest +to False whenever the printer reports backup OFF, with None (unknown / A1 +family) preserving today's behaviour. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.services.print_scheduler import PrintScheduler + + +@pytest.fixture +def scheduler(): + return PrintScheduler() + + +def _patch_status(backup): + """Patch ``printer_manager.get_status`` to return a stub PrinterState whose + ``ams_filament_backup`` is the requested tri-state value.""" + return patch( + "backend.app.services.print_scheduler.printer_manager.get_status", + return_value=SimpleNamespace(ams_filament_backup=backup, raw_data={}), + ) + + +async def _run_with_backup(scheduler, backup_state, prefer_lowest_setting): + """Drive ``_compute_ams_mapping_for_printer`` past the gate point and + return whatever ``prefer_lowest`` value gets handed to the matcher.""" + db = MagicMock() + item = SimpleNamespace(filament_overrides=None) + filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}] + loaded = [ + { + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "is_external": False, + "type": "PLA", + "color": "#000000", + "tray_info_idx": "", + }, + ] + + captured: dict = {} + + def _capture_match(reqs, loaded_, prefer, overrides): + captured["prefer_lowest"] = prefer + return [0] + + with ( + _patch_status(backup_state), + patch.object(scheduler, "_get_filament_requirements", new=AsyncMock(return_value=filament_reqs)), + patch.object(scheduler, "_build_loaded_filaments", return_value=loaded), + patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=prefer_lowest_setting)), + patch.object(scheduler, "_build_inventory_remain_overrides", new=AsyncMock(return_value={})), + patch.object(scheduler, "_match_filaments_to_slots", side_effect=_capture_match), + ): + await scheduler._compute_ams_mapping_for_printer(db, printer_id=1, item=item) + + return captured.get("prefer_lowest") + + +class TestPreferLowestBackupGate: + @pytest.mark.asyncio + async def test_backup_off_disables_prefer_lowest(self, scheduler): + # User setting ON but printer reports backup OFF — gate must coerce. + # This is the #1766 fix: previously the sort applied and picked the + # near-empty spool, leaving the print to fail mid-job. + out = await _run_with_backup(scheduler, backup_state=False, prefer_lowest_setting=True) + assert out is False + + @pytest.mark.asyncio + async def test_backup_on_preserves_prefer_lowest(self, scheduler): + # Backup ON — sort applies as the user intended. + out = await _run_with_backup(scheduler, backup_state=True, prefer_lowest_setting=True) + assert out is True + + @pytest.mark.asyncio + async def test_backup_unknown_preserves_prefer_lowest(self, scheduler): + # None = unknown / unsupported (A1 family). Must NOT be treated as OFF + # — that would regress A1 users who currently get the sort applied. + out = await _run_with_backup(scheduler, backup_state=None, prefer_lowest_setting=True) + assert out is True + + @pytest.mark.asyncio + async def test_user_setting_off_short_circuits(self, scheduler): + # User setting OFF — backup state is irrelevant; sort never applies. + out = await _run_with_backup(scheduler, backup_state=True, prefer_lowest_setting=False) + assert out is False diff --git a/frontend/src/__tests__/components/PrinterSelector.test.ts b/frontend/src/__tests__/components/PrinterSelector.test.ts index 936c9e194..0f505d59b 100644 --- a/frontend/src/__tests__/components/PrinterSelector.test.ts +++ b/frontend/src/__tests__/components/PrinterSelector.test.ts @@ -247,3 +247,40 @@ describe('autoMatchFilament preferLowest', () => { expect(result!.globalTrayId).toBe(1); // Only tray on correct nozzle }); }); + +// #1766: identical-material spools that only differ in inventory grams used to +// tie at the printer's `remain%` and the first one always won. The map lets +// the sort see the bound spool's `label_weight - weight_used` instead. +describe('autoMatchFilament preferLowest with inventory map (#1766)', () => { + it('picks lower inventory grams when remain% ties', () => { + const filaments = [ + makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 100 }), + makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 100 }), + ]; + const req = makeReq({ type: 'PLA', color: '#FF0000' }); + const inventory = new Map([[0, 900], [1, 60]]); + const result = autoMatchFilament(req, filaments, new Set(), true, inventory); + expect(result!.globalTrayId).toBe(1); // 60 g < 900 g + }); + + it('inventory-bound spool beats MQTT-only one even when remain% would order them differently', () => { + const filaments = [ + makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 10 }), + makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 90 }), + ]; + const req = makeReq({ type: 'PLA', color: '#FF0000' }); + const inventory = new Map([[1, 200]]); // Only tray 1 bound. + const result = autoMatchFilament(req, filaments, new Set(), true, inventory); + expect(result!.globalTrayId).toBe(1); // Tier 0 always beats tier 1. + }); + + it('falls back to remain% sort when map is undefined', () => { + const filaments = [ + makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }), + makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }), + ]; + const req = makeReq({ type: 'PLA', color: '#FF0000' }); + const result = autoMatchFilament(req, filaments, new Set(), true, undefined); + expect(result!.globalTrayId).toBe(1); // Same as the pre-#1766 path. + }); +}); diff --git a/frontend/src/__tests__/hooks/useFilamentMapping.test.ts b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts index 98b12553b..66264d44c 100644 --- a/frontend/src/__tests__/hooks/useFilamentMapping.test.ts +++ b/frontend/src/__tests__/hooks/useFilamentMapping.test.ts @@ -10,6 +10,7 @@ import { buildLoadedFilaments, computeAmsMapping, } from '../../hooks/useFilamentMapping'; +import { effectivePreferLowest } from '../../utils/amsHelpers'; import type { PrinterStatus } from '../../api/client'; // Helper to create a minimal printer status with AMS data @@ -1048,3 +1049,145 @@ describe('computeAmsMapping preferLowest', () => { expect(result).toEqual([1]); // Known 60% over unknown }); }); + +// #1766: the user reported that "Prefer lowest remaining filament" picked the +// wrong spool when two identical-material/color spools differed only in the +// inventory-tracked grams (not the printer's `remain%`). The pre-fix sort +// looked at `remain%` only and ignored Bambuddy's bound inventory entirely; +// now we pass a globalTrayId -> grams map and the sort lifts inventory-bound +// spools to tier 0 (matching backend _prefer_lowest_sort_key). +describe('computeAmsMapping preferLowest with inventory map (#1766)', () => { + it('picks spool with lower inventory grams when both spools report same remain%', () => { + // Reporter's scenario: two identical Bambu-branded spools, both report + // `remain=100` because they were freshly inserted, but inventory has them + // at 950 g vs 50 g remaining. Pre-fix sort ties and picks the first. + const reqs = { + filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 }, + { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 }, + ], + }, + ]); + const inventory = new Map([[0, 950], [1, 50]]); + + const result = computeAmsMapping(reqs, status, true, inventory); + expect(result).toEqual([1]); // Inventory says tray 1 is nearly empty — use it first. + }); + + it('prefers inventory-tracked spool over non-tracked one even when remain% would order them differently', () => { + // Two spools both match by type+color, both have the same tray_info_idx + // (identical SKU). Tray 0 has no inventory binding but reports remain=20. + // Tray 1 has an inventory binding with 100 g remaining. Tier 0 (bound) + // always beats tier 1 (MQTT-only) regardless of value — matches backend. + const reqs = { + filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 20 }, + { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 80 }, + ], + }, + ]); + const inventory = new Map([[1, 100]]); // Only tray 1 bound + + const result = computeAmsMapping(reqs, status, true, inventory); + expect(result).toEqual([1]); + }); + + it('falls back to remain% sort when no inventory map provided (pre-#1766 behaviour)', () => { + // Regression guard: callers that haven't yet wired the map must get the + // same sort they always got. None of the existing tests in this file pass + // a map; this asserts the default path is unchanged. + const reqs = { + filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 }, + { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 }, + ], + }, + ]); + + const result = computeAmsMapping(reqs, status, true, undefined); + expect(result).toEqual([1]); // Same as the existing no-inventory case. + }); +}); + +// #1766 safety gate: when the printer has AMS Filament Backup OFF, the sort +// MUST NOT run, even with the user setting on. Otherwise the dispatch picks a +// near-empty spool the printer can't switch off of when it runs out mid-print. +// Mirrors backend `_compute_ams_mapping_for_printer` gate. +describe('effectivePreferLowest gate (#1766)', () => { + it('coerces to false when backup is OFF', () => { + expect(effectivePreferLowest(true, false)).toBe(false); + }); + + it('passes through when backup is ON', () => { + expect(effectivePreferLowest(true, true)).toBe(true); + }); + + it('passes through when backup is unknown (null/undefined — A1 family)', () => { + expect(effectivePreferLowest(true, null)).toBe(true); + expect(effectivePreferLowest(true, undefined)).toBe(true); + }); + + it('stays false when the user setting is off, regardless of backup state', () => { + expect(effectivePreferLowest(false, true)).toBe(false); + expect(effectivePreferLowest(false, false)).toBe(false); + expect(effectivePreferLowest(undefined, true)).toBe(false); + }); + + it('slot-priority tie-break: external/VT spools sort AFTER regular AMS', () => { + // Mirrors backend `_slot_priority` banding. When tier and value tie, slot + // position decides — external (ams_id = -1) must clamp to 10_000 so it + // can't beat AMS slot 0 (priority 0). + const reqs = { + filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }], + }; + const status = createPrinterStatus( + [ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }, // priority 0 + ], + }, + ], + [{ id: 254, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }], // priority 10_000 + ); + const result = computeAmsMapping(reqs, status, true); + expect(result).toEqual([0]); // AMS slot wins the tie; VT does not. + }); + + it('end-to-end: backup OFF prevents lowest-pick at dispatch (caller-coerced)', () => { + // PrintModal computes the effective flag and passes it to computeAmsMapping. + // This pins that flow: with backup=false the flag becomes false, the sort + // doesn't run, and the first matching tray wins (today's behaviour). + const reqs = { + filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }], + }; + const status = createPrinterStatus([ + { + id: 0, + tray: [ + { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 }, + { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 5 }, // near-empty + ], + }, + ]); + const gated = effectivePreferLowest(true, false); + const result = computeAmsMapping(reqs, status, gated); + expect(result).toEqual([0]); // First match wins; the 5%-remain spool is NOT selected. + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 15b73822a..3354258f7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -502,6 +502,9 @@ export interface PrinterStatus { firmware_version: string | null; // Firmware version from MQTT // Developer LAN mode: true = enabled, false = disabled, null = unknown developer_mode: boolean | null; + // AMS Filament Backup ("auto-switch" to a backup spool when one runs out). + // true = ON, false = OFF, null = unknown / unsupported (A1 family). + ams_filament_backup: boolean | null; // Queue: printer is awaiting user ack that the build plate was cleared after a // finished/failed print. Persisted across restarts (#961). awaiting_plate_clear: boolean; @@ -3632,6 +3635,22 @@ export const api = { { method: 'POST' } ), + // AMS Filament Backup (auto-switch to a backup spool when one runs out) + setAmsFilamentBackup: (printerId: number, enabled: boolean) => + request<{ success: boolean; ams_filament_backup: boolean }>( + `/printers/${printerId}/ams-backup?enabled=${enabled}`, + { method: 'POST' } + ), + + // Per-globalTrayId remaining grams for this printer's inventory-bound slots + // (#1766). Drives the client-side "Prefer Lowest Remaining Filament" sort + // when computing the AMS mapping; mirrors backend `_build_inventory_remain_overrides` + // so internal and Spoolman modes both work uniformly. + getInventoryRemain: (printerId: number) => + request<{ inventory_remain_g: Record }>( + `/printers/${printerId}/inventory-remain`, + ), + // Skip Objects getPrintableObjects: (printerId: number) => request<{ diff --git a/frontend/src/components/PrintModal/FilamentMapping.tsx b/frontend/src/components/PrintModal/FilamentMapping.tsx index 2230849cf..23a01313a 100644 --- a/frontend/src/components/PrintModal/FilamentMapping.tsx +++ b/frontend/src/components/PrintModal/FilamentMapping.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react'; import { api } from '../../api/client'; import { useFilamentMapping } from '../../hooks/useFilamentMapping'; -import { getGlobalTrayId } from '../../utils/amsHelpers'; +import { getGlobalTrayId, effectivePreferLowest } from '../../utils/amsHelpers'; import { getColorName } from '../../utils/colors'; import { useFilamentLabels } from './useFilamentLabels'; import type { FilamentMappingProps } from './types'; @@ -42,8 +42,35 @@ export function FilamentMapping({ enabled: !!printerId, }); + // Settings + inventory map drive the same prefer-lowest + AMS-backup gate + // the dispatcher uses (#1766). Without this, the per-slot dropdown's + // auto-suggestion could disagree with what actually gets dispatched. + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + }); + const { data: inventoryRemain } = useQuery({ + queryKey: ['printer-inventory-remain', printerId], + queryFn: () => api.getInventoryRemain(printerId), + enabled: !!printerId, + staleTime: 30 * 1000, + }); + const inventoryByTrayId = useMemo(() => { + if (!inventoryRemain?.inventory_remain_g) return undefined; + const map = new Map(); + Object.entries(inventoryRemain.inventory_remain_g).forEach(([key, grams]) => { + const gtid = Number(key); + if (!Number.isNaN(gtid)) map.set(gtid, grams); + }); + return map; + }, [inventoryRemain]); + const gatedPreferLowest = effectivePreferLowest( + settings?.prefer_lowest_filament, + printerStatus?.ams_filament_backup, + ); + const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } = - useFilamentMapping(filamentReqs, printerStatus, manualMappings); + useFilamentMapping(filamentReqs, printerStatus, manualMappings, gatedPreferLowest, inventoryByTrayId); // Per-slot sub-brand + material-disambiguated colour labels (#1718). Same // shared hook the model-mode FilamentOverride uses so both panels render diff --git a/frontend/src/components/PrintModal/PrinterSelector.tsx b/frontend/src/components/PrintModal/PrinterSelector.tsx index 629036e5b..b88eb8c28 100644 --- a/frontend/src/components/PrintModal/PrinterSelector.tsx +++ b/frontend/src/components/PrintModal/PrinterSelector.tsx @@ -18,6 +18,7 @@ import { colorsAreSimilar, autoMatchFilament, filterFilamentsByNozzle, + effectivePreferLowest, } from '../../utils/amsHelpers'; import type { PrinterSelectorProps, AssignmentMode } from './types'; import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping'; @@ -108,7 +109,13 @@ function InlineMappingEditor({ } else { const usedTrayIds = new Set(Object.values(printerResult.config.manualMappings)); const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']); - loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds, cachedSettings?.prefer_lowest_filament) as LoadedFilament | undefined; + loaded = autoMatchFilament( + req, + printerResult.loadedFilaments, + usedTrayIds, + effectivePreferLowest(cachedSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup), + printerResult.inventoryByTrayId, + ) as LoadedFilament | undefined; } // Determine status diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 16a0ebe6f..70396ae9c 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -15,7 +15,7 @@ import { getColorName } from '../../utils/colors'; import { getCurrencySymbol } from '../../utils/currency'; import { getBedTypeInfo } from '../../utils/bedType'; import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date'; -import { getGlobalTrayId, isPlaceholderDate } from '../../utils/amsHelpers'; +import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers'; import { FilamentMapping } from './FilamentMapping'; import { FilamentOverride } from './FilamentOverride'; import { PlateSelector } from './PlateSelector'; @@ -270,6 +270,34 @@ export function PrintModal({ enabled: ((mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer') || (isLibraryFile && mode === 'reprint'), }); + // Fetch per-printer Map via the dedicated + // backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides` + // so internal and Spoolman modes both work uniformly, VT/external slots are + // excluded, and negative grams are clamped — single source of truth between + // the client-side preview and dispatch-time picks. + const inventoryRemainQueries = useQueries({ + queries: selectedPrinters.map((printerId) => ({ + queryKey: ['printer-inventory-remain', printerId], + queryFn: () => api.getInventoryRemain(printerId), + staleTime: 30 * 1000, + enabled: selectedPrinters.length > 0, + })), + }); + const inventoryByTrayIdPerPrinter = useMemo(() => { + const result = new Map>(); + selectedPrinters.forEach((printerId, idx) => { + const data = inventoryRemainQueries[idx]?.data?.inventory_remain_g; + if (!data) return; + const printerMap = new Map(); + Object.entries(data).forEach(([key, grams]) => { + const gtid = Number(key); + if (!Number.isNaN(gtid)) printerMap.set(gtid, grams); + }); + result.set(printerId, printerMap); + }); + return result; + }, [selectedPrinters, inventoryRemainQueries]); + // Fetch archive details to get sliced_for_model const { data: archiveDetails } = useQuery({ queryKey: ['archive', archiveId], @@ -346,8 +374,22 @@ export function PrintModal({ enabled: !!effectivePrinterId, }); + // Single-printer flow: gate prefer_lowest on this printer's backup state. + // Multi-printer flow gates per-printer inside the hook (different printers + // may have different backup states), so we pass the raw setting down. + const singlePrinterPreferLowest = effectivePreferLowest( + settings?.prefer_lowest_filament, + printerStatus?.ams_filament_backup, + ); + // Get AMS mapping from hook (only when single printer selected) - const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings, settings?.prefer_lowest_filament); + const { amsMapping } = useFilamentMapping( + effectiveFilamentReqs, + printerStatus, + manualMappings, + singlePrinterPreferLowest, + effectivePrinterId ? inventoryByTrayIdPerPrinter.get(effectivePrinterId) : undefined, + ); // Multi-printer filament mapping (for per-printer configuration) const multiPrinterMapping = useMultiPrinterFilamentMapping( @@ -358,6 +400,7 @@ export function PrintModal({ perPrinterConfigs, setPerPrinterConfigs, settings?.prefer_lowest_filament, + inventoryByTrayIdPerPrinter, ); // Auto-select first plate when plates load (single or multi-plate) diff --git a/frontend/src/hooks/useFilamentMapping.ts b/frontend/src/hooks/useFilamentMapping.ts index 8fc920a85..c7ca746c0 100644 --- a/frontend/src/hooks/useFilamentMapping.ts +++ b/frontend/src/hooks/useFilamentMapping.ts @@ -6,6 +6,8 @@ import { colorsAreSimilar, formatSlotLabel, getGlobalTrayId, + preferLowestSortKey, + compareSortKeys, } from '../utils/amsHelpers'; import type { PrinterStatus } from '../api/client'; @@ -102,6 +104,7 @@ export function computeAmsMapping( filamentReqs: { filaments: FilamentRequirement[] } | undefined, printerStatus: PrinterStatus | undefined, preferLowest?: boolean, + inventoryByTrayId?: Map, ): number[] | undefined { if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined; @@ -129,13 +132,15 @@ export function computeAmsMapping( available = available.filter((f) => f.extruderId === req.nozzle_id); } - // Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first + // Sort lowest-first when the preference is on. Inventory-tracked spools + // sort before MQTT-only ones; see preferLowestSortKey for the rationale. if (preferLowest) { - available = [...available].sort((a, b) => { - const ra = a.remain >= 0 ? a.remain : 101; - const rb = b.remain >= 0 ? b.remain : 101; - return ra - rb; - }); + available = [...available].sort((a, b) => + compareSortKeys( + preferLowestSortKey(a, inventoryByTrayId), + preferLowestSortKey(b, inventoryByTrayId), + ), + ); } let idxMatch: LoadedFilament | undefined; @@ -152,11 +157,12 @@ export function computeAmsMapping( } else if (idxMatches.length > 1) { // Multiple trays with same tray_info_idx - use color matching among them if (preferLowest) { - idxMatches.sort((a, b) => { - const ra = a.remain >= 0 ? a.remain : 101; - const rb = b.remain >= 0 ? b.remain : 101; - return ra - rb; - }); + idxMatches.sort((a, b) => + compareSortKeys( + preferLowestSortKey(a, inventoryByTrayId), + preferLowestSortKey(b, inventoryByTrayId), + ), + ); } exactMatch = idxMatches.find( (f) => @@ -325,6 +331,7 @@ export function useFilamentMapping( printerStatus: PrinterStatus | undefined, manualMappings: Record, preferLowest?: boolean, + inventoryByTrayId?: Map, ): UseFilamentMappingResult { const loadedFilaments = useLoadedFilaments(printerStatus); @@ -389,13 +396,15 @@ export function useFilamentMapping( available = available.filter((f) => f.extruderId === req.nozzle_id); } - // Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first + // Sort lowest-first when the preference is on. Inventory-tracked spools + // sort before MQTT-only ones; see preferLowestSortKey for the rationale. if (preferLowest) { - available = [...available].sort((a, b) => { - const ra = a.remain >= 0 ? a.remain : 101; - const rb = b.remain >= 0 ? b.remain : 101; - return ra - rb; - }); + available = [...available].sort((a, b) => + compareSortKeys( + preferLowestSortKey(a, inventoryByTrayId), + preferLowestSortKey(b, inventoryByTrayId), + ), + ); } let idxMatch: LoadedFilament | undefined; @@ -412,11 +421,12 @@ export function useFilamentMapping( } else if (idxMatches.length > 1) { // Multiple trays with same tray_info_idx - use color matching among them if (preferLowest) { - idxMatches.sort((a, b) => { - const ra = a.remain >= 0 ? a.remain : 101; - const rb = b.remain >= 0 ? b.remain : 101; - return ra - rb; - }); + idxMatches.sort((a, b) => + compareSortKeys( + preferLowestSortKey(a, inventoryByTrayId), + preferLowestSortKey(b, inventoryByTrayId), + ), + ); } exactMatch = idxMatches.find( (f) => @@ -491,7 +501,7 @@ export function useFilamentMapping( isManual: false, }; }); - }, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive]); + }, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId]); // Build AMS mapping from matched filaments // Format: array matching 3MF filament slot structure diff --git a/frontend/src/hooks/useMultiPrinterFilamentMapping.ts b/frontend/src/hooks/useMultiPrinterFilamentMapping.ts index 7eadae32a..b687d8c5e 100644 --- a/frontend/src/hooks/useMultiPrinterFilamentMapping.ts +++ b/frontend/src/hooks/useMultiPrinterFilamentMapping.ts @@ -11,6 +11,9 @@ import { import { normalizeColorForCompare, colorsAreSimilar, + preferLowestSortKey, + compareSortKeys, + effectivePreferLowest, } from '../utils/amsHelpers'; /** @@ -58,6 +61,8 @@ export interface PrinterMappingResult { totalSlots: number; /** Per-printer config */ config: PerPrinterConfig; + /** Per-globalTrayId inventory grams remaining, for the lowest-remain sort (#1766) */ + inventoryByTrayId?: Map; } /** @@ -90,6 +95,7 @@ function computeMatchDetails( loadedFilaments: LoadedFilament[], manualMappings: Record, preferLowest?: boolean, + inventoryByTrayId?: Map, ): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } { if (!filamentReqs || filamentReqs.length === 0) { return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' }; @@ -135,11 +141,12 @@ function computeMatchDetails( } if (preferLowest) { - candidates = [...candidates].sort((a, b) => { - const ra = a.remain >= 0 ? a.remain : 101; - const rb = b.remain >= 0 ? b.remain : 101; - return ra - rb; - }); + candidates = [...candidates].sort((a, b) => + compareSortKeys( + preferLowestSortKey(a, inventoryByTrayId), + preferLowestSortKey(b, inventoryByTrayId), + ), + ); } const exactMatch = candidates.find( @@ -194,6 +201,7 @@ function computeMappingWithOverrides( printerStatus: PrinterStatus | undefined, manualMappings: Record, preferLowest?: boolean, + inventoryByTrayId?: Map, ): number[] | undefined { if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined; @@ -222,11 +230,12 @@ function computeMappingWithOverrides( } if (preferLowest) { - candidates = [...candidates].sort((a, b) => { - const ra = a.remain >= 0 ? a.remain : 101; - const rb = b.remain >= 0 ? b.remain : 101; - return ra - rb; - }); + candidates = [...candidates].sort((a, b) => + compareSortKeys( + preferLowestSortKey(a, inventoryByTrayId), + preferLowestSortKey(b, inventoryByTrayId), + ), + ); } const exactMatch = candidates.find( @@ -290,6 +299,7 @@ export function useMultiPrinterFilamentMapping( perPrinterConfigs: Record, setPerPrinterConfigs: React.Dispatch>>, preferLowest?: boolean, + inventoryByTrayIdPerPrinter?: Map>, ): UseMultiPrinterFilamentMappingResult { // Fetch printer status for all selected printers in parallel const statusQueries = useQueries({ @@ -311,9 +321,14 @@ export function useMultiPrinterFilamentMapping( const loadedFilaments = buildLoadedFilaments(printerStatus); const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG; + const inventoryByTrayId = inventoryByTrayIdPerPrinter?.get(printerId); + // Per-printer gate (#1766): two printers in the same dispatch can have + // different AMS Backup states; the sort must be skipped on the OFF ones + // and kept on the ON ones. Computing inside the loop captures both. + const printerPreferLowest = effectivePreferLowest(preferLowest, printerStatus?.ams_filament_backup); // Compute auto mapping for this printer - const autoMapping = computeAmsMapping(filamentReqs, printerStatus, preferLowest); + const autoMapping = computeAmsMapping(filamentReqs, printerStatus, printerPreferLowest, inventoryByTrayId); // Determine which mappings to use: // If printer has override (useDefault=false), use its custom mappings @@ -323,14 +338,15 @@ export function useMultiPrinterFilamentMapping( : defaultMappings; // Compute final mapping with overrides - const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, preferLowest); + const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, printerPreferLowest, inventoryByTrayId); // Compute match details const matchDetails = computeMatchDetails( filamentReqs?.filaments, loadedFilaments, effectiveMappings, - preferLowest, + printerPreferLowest, + inventoryByTrayId, ); return { @@ -347,9 +363,10 @@ export function useMultiPrinterFilamentMapping( missingTypes: matchDetails.missingTypes, totalSlots: matchDetails.totalSlots, config, + inventoryByTrayId, }; }); - }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest]); + }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest, inventoryByTrayIdPerPrinter]); const isLoading = statusQueries.some((q) => q.isLoading); @@ -370,7 +387,12 @@ export function useMultiPrinterFilamentMapping( if (!result || !result.status || !filamentReqs?.filaments) return; // Compute optimal mapping for this printer - const autoMapping = computeAmsMapping(filamentReqs, result.status, preferLowest); + const autoMapping = computeAmsMapping( + filamentReqs, + result.status, + effectivePreferLowest(preferLowest, result.status?.ams_filament_backup), + inventoryByTrayIdPerPrinter?.get(printerId), + ); if (!autoMapping) return; // Convert autoMapping array to manualMappings record diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 803076196..0e4d0ff47 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -516,6 +516,13 @@ export default { stoppingDrying: 'Trocknung wird gestoppt...', rotateTray: 'Spule während der Trocknung drehen', }, + amsBackup: { + titleOn: 'AMS Filament Backup ist EIN. Zum Deaktivieren klicken.', + titleOff: 'AMS Filament Backup ist AUS. Zum Aktivieren klicken.', + titleUnknown: 'AMS-Filament-Backup-Status auf diesem Drucker nicht verfügbar.', + toastEnabled: 'AMS Filament Backup aktiviert', + toastDisabled: 'AMS Filament Backup deaktiviert', + }, // Filaments section filaments: 'Filamente', // Camera @@ -1718,6 +1725,7 @@ export default { disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen', preferLowestFilament: 'Niedrigsten Filamentrest bevorzugen', preferLowestFilamentDesc: 'Bei mehreren passenden Spulen die mit dem geringsten Restfilament verwenden', + preferLowestFilamentBackupNote: 'Wirkt nur, wenn AMS Filament Backup am Drucker aktiviert ist — sonst kann der Drucker beim Aufbrauchen der ersten Spule nicht auf eine zweite Spule wechseln.', trackingModeBuiltIn: 'Integriertes Inventar', trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive', trackingModeSpoolmanDesc: 'Externer Filament-Management-Server', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c54ef8f4c..23c4d1bf3 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -516,6 +516,14 @@ export default { stoppingDrying: 'Stopping drying...', rotateTray: 'Rotate spool during drying', }, + // AMS Filament Backup status badge (printer-wide auto-switch to another spool) + amsBackup: { + titleOn: 'AMS Filament Backup is ON. Click to disable.', + titleOff: 'AMS Filament Backup is OFF. Click to enable.', + titleUnknown: 'AMS Filament Backup status unavailable on this printer.', + toastEnabled: 'AMS Filament Backup enabled', + toastDisabled: 'AMS Filament Backup disabled', + }, // Filaments section filaments: 'Filaments', // Camera @@ -1728,6 +1736,7 @@ export default { disableFilamentWarningsDesc: 'Don\'t show warnings about insufficient filament when printing or queueing', preferLowestFilament: 'Prefer lowest remaining filament', preferLowestFilamentDesc: 'When multiple spools match, use the one with the least filament remaining', + preferLowestFilamentBackupNote: 'Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out.', trackingModeBuiltIn: 'Built-in Inventory', trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included', trackingModeSpoolmanDesc: 'External filament management server', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index e100f3284..fb43b4c6c 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -516,6 +516,13 @@ export default { stoppingDrying: 'Deteniendo el secado...', rotateTray: 'Girar la bobina durante el secado', }, + amsBackup: { + titleOn: 'AMS Filament Backup está ACTIVADO. Haz clic para desactivar.', + titleOff: 'AMS Filament Backup está DESACTIVADO. Haz clic para activar.', + titleUnknown: 'Estado de AMS Filament Backup no disponible en esta impresora.', + toastEnabled: 'AMS Filament Backup activado', + toastDisabled: 'AMS Filament Backup desactivado', + }, // Filaments section filaments: 'Filamentos', // Camera @@ -1721,6 +1728,7 @@ export default { disableFilamentWarningsDesc: 'No mostrar advertencias sobre filamento insuficiente al imprimir o encolar', preferLowestFilament: 'Preferir el filamento con menos restante', preferLowestFilamentDesc: 'Cuando varias bobinas coincidan, usar la que tenga menos filamento restante', + preferLowestFilamentBackupNote: 'Solo surte efecto cuando AMS Filament Backup está activado en la impresora — de lo contrario, la impresora no puede cambiar a una segunda bobina cuando la elegida se acabe.', trackingModeBuiltIn: 'Inventario integrado', trackingModeBuiltInDesc: 'Incluye coincidencia automática por RFID y seguimiento del uso', trackingModeSpoolmanDesc: 'Servidor externo de gestión de filamento', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index cf4cdf071..e4de4c046 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -516,6 +516,13 @@ export default { stoppingDrying: 'Arrêt du séchage...', rotateTray: 'Tourner la bobine pendant le séchage', }, + amsBackup: { + titleOn: "AMS Filament Backup est ACTIVÉ. Cliquez pour désactiver.", + titleOff: "AMS Filament Backup est DÉSACTIVÉ. Cliquez pour activer.", + titleUnknown: "État de l'AMS Filament Backup indisponible sur cette imprimante.", + toastEnabled: "AMS Filament Backup activé", + toastDisabled: "AMS Filament Backup désactivé", + }, // Filaments section filaments: 'Filaments', // Camera @@ -1674,6 +1681,7 @@ export default { disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\'impression ou de la mise en file d\'attente', preferLowestFilament: 'Préférer le filament le plus bas', preferLowestFilamentDesc: 'Lorsque plusieurs bobines correspondent, utiliser celle avec le moins de filament restant', + preferLowestFilamentBackupNote: "Ne prend effet que si AMS Filament Backup est activé sur l'imprimante — sinon, l'imprimante ne peut pas basculer vers une seconde bobine quand celle choisie est vide.", trackingModeBuiltIn: 'Inventaire Intégré', trackingModeBuiltInDesc: 'Correspondance RFID et suivi de consommation inclus', trackingModeSpoolmanDesc: 'Serveur de gestion externe', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index b3cb8fa55..bf7e0156e 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -516,6 +516,13 @@ export default { stoppingDrying: 'Arresto essiccazione...', rotateTray: 'Ruota la bobina durante l\'essiccazione', }, + amsBackup: { + titleOn: 'AMS Filament Backup è ATTIVO. Clicca per disabilitare.', + titleOff: 'AMS Filament Backup è DISATTIVATO. Clicca per abilitare.', + titleUnknown: 'Stato di AMS Filament Backup non disponibile su questa stampante.', + toastEnabled: 'AMS Filament Backup abilitato', + toastDisabled: 'AMS Filament Backup disabilitato', + }, // Filaments section filaments: 'Filamenti', // Camera @@ -1674,6 +1681,7 @@ export default { disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\'accodamento', preferLowestFilament: 'Preferisci il filamento con meno residuo', preferLowestFilamentDesc: 'Quando più bobine corrispondono, usa quella con meno filamento rimanente', + preferLowestFilamentBackupNote: 'Ha effetto solo se AMS Filament Backup è abilitato sulla stampante — altrimenti la stampante non può passare a una seconda bobina quando quella scelta finisce.', trackingModeBuiltIn: 'Inventario integrato', trackingModeBuiltInDesc: 'Riconoscimento RFID automatico e tracciamento dell\'uso inclusi', trackingModeSpoolmanDesc: 'Server esterno per la gestione del filamento', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 0290ef5f9..1b0b7da70 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -515,6 +515,13 @@ export default { stoppingDrying: '乾燥を停止しています...', rotateTray: '乾燥中にスプールを回転', }, + amsBackup: { + titleOn: 'AMSフィラメントバックアップはONです。クリックして無効化します。', + titleOff: 'AMSフィラメントバックアップはOFFです。クリックして有効化します。', + titleUnknown: 'このプリンタではAMSフィラメントバックアップ状態を確認できません。', + toastEnabled: 'AMSフィラメントバックアップを有効化しました', + toastDisabled: 'AMSフィラメントバックアップを無効化しました', + }, // Filaments section filaments: 'フィラメント', // Camera @@ -1717,6 +1724,7 @@ export default { disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない', preferLowestFilament: '残量が少ないフィラメントを優先', preferLowestFilamentDesc: '複数のスプールが一致する場合、残量が最も少ないものを使用します', + preferLowestFilamentBackupNote: 'プリンタ側でAMSフィラメントバックアップが有効な場合のみ動作します。無効の場合、選択したスプールが切れても予備スプールに切り替えできません。', trackingModeBuiltIn: '内蔵インベントリ', trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む', trackingModeSpoolmanDesc: '外部フィラメント管理サーバー', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index bd49c9fde..7f2f79773 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -479,6 +479,13 @@ export default { stoppingDrying: '건조 정지 중...', rotateTray: '건조 중 스풀 회전' }, + amsBackup: { + titleOn: 'AMS 필라멘트 백업이 켜져 있습니다. 비활성화하려면 클릭하세요.', + titleOff: 'AMS 필라멘트 백업이 꺼져 있습니다. 활성화하려면 클릭하세요.', + titleUnknown: '이 프린터에서는 AMS 필라멘트 백업 상태를 확인할 수 없습니다.', + toastEnabled: 'AMS 필라멘트 백업이 활성화되었습니다', + toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다' + }, filaments: '필라멘트', openCameraOverlay: '카메라 오버레이 열기', openCameraWindow: '새 창에서 카메라 열기', @@ -1625,6 +1632,7 @@ export default { disableFilamentWarningsDesc: '인쇄 또는 대기열 추가 시 필라멘트 부족 경고 표시 안 함', preferLowestFilament: '남은 필라멘트가 가장 적은 것 우선', preferLowestFilamentDesc: '여러 스풀이 일치할 때 남은 필라멘트가 가장 적은 것 사용', + preferLowestFilamentBackupNote: '프린터에서 AMS 필라멘트 백업이 활성화된 경우에만 적용됩니다. 그렇지 않으면 선택한 스풀이 다 떨어졌을 때 두 번째 스풀로 전환할 수 없습니다.', trackingModeBuiltIn: '내장 인벤토리', trackingModeBuiltInDesc: 'RFID 자동 매칭 및 사용량 추적 포함', trackingModeSpoolmanDesc: '외부 필라멘트 관리 서버', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 318353d5d..439f9321a 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -516,6 +516,13 @@ export default { stoppingDrying: 'Parando secagem...', rotateTray: 'Girar o carretel durante a secagem', }, + amsBackup: { + titleOn: 'AMS Filament Backup está LIGADO. Clique para desativar.', + titleOff: 'AMS Filament Backup está DESLIGADO. Clique para ativar.', + titleUnknown: 'Estado do AMS Filament Backup indisponível nesta impressora.', + toastEnabled: 'AMS Filament Backup ativado', + toastDisabled: 'AMS Filament Backup desativado', + }, // Filaments section filaments: 'Filamentos', // Camera @@ -1674,6 +1681,7 @@ export default { disableFilamentWarningsDesc: 'Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila', preferLowestFilament: 'Preferir filamento com menor resto', preferLowestFilamentDesc: 'Quando vários carretéis correspondem, usar o com menos filamento restante', + preferLowestFilamentBackupNote: 'Só tem efeito quando o AMS Filament Backup está habilitado na impressora — caso contrário, a impressora não pode trocar para um segundo carretel quando o escolhido acabar.', trackingModeBuiltIn: 'Inventário Interno', trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos', trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 0b615cfc2..493762371 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -516,6 +516,13 @@ export default { stoppingDrying: 'Kurutma durduruluyor...', rotateTray: 'Kurutma sırasında makarayı döndür', }, + amsBackup: { + titleOn: 'AMS Filament Backup AÇIK. Devre dışı bırakmak için tıklayın.', + titleOff: 'AMS Filament Backup KAPALI. Etkinleştirmek için tıklayın.', + 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ı', + }, // Filamentler bölümü filaments: 'Filamentler', // Kamera @@ -1721,6 +1728,7 @@ export default { disableFilamentWarningsDesc: 'Yazdırırken veya kuyruğa eklerken yetersiz filamentle ilgili uyarıları gösterme', preferLowestFilament: 'En az kalan filamenti tercih et', preferLowestFilamentDesc: 'Birden fazla makara eşleştiğinde, kalan filamenti en az olanı kullan', + preferLowestFilamentBackupNote: 'Yalnızca yazıcıda AMS Filament Backup etkinleştirildiğinde uygulanır — aksi takdirde seçilen makara bittiğinde yazıcı ikinci bir makaraya geçemez.', trackingModeBuiltIn: 'Yerleşik Envanter', trackingModeBuiltInDesc: 'RFID otomatik eşleştirme ve kullanım takibi dahil', trackingModeSpoolmanDesc: 'Harici filament yönetim sunucusu', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 2c9bf3e2c..acdecd578 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -516,6 +516,13 @@ export default { stoppingDrying: '正在停止干燥...', rotateTray: '干燥时旋转料盘', }, + amsBackup: { + titleOn: 'AMS 备用料盘已开启。点击以禁用。', + titleOff: 'AMS 备用料盘已关闭。点击以启用。', + titleUnknown: '本打印机不支持读取 AMS 备用料盘状态。', + toastEnabled: 'AMS 备用料盘已启用', + toastDisabled: 'AMS 备用料盘已禁用', + }, // Filaments section filaments: '耗材', // Camera @@ -1719,6 +1726,7 @@ export default { disableFilamentWarningsDesc: '在打印或加入队列时不显示耗材不足警告', preferLowestFilament: '优先使用剩余最少的耗材', preferLowestFilamentDesc: '当多个料盘匹配时,使用剩余耗材最少的那个', + preferLowestFilamentBackupNote: '仅当打印机端启用了 AMS 备用料盘时才生效——否则当选中的料盘耗尽时,打印机无法切换到备用料盘。', trackingModeBuiltIn: '内置库存', trackingModeBuiltInDesc: '包含 RFID 自动匹配和用量追踪', trackingModeSpoolmanDesc: '外部耗材管理服务器', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 6a75d6304..da5e9a915 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -516,6 +516,13 @@ export default { stoppingDrying: '正在停止乾燥...', rotateTray: '乾燥時旋轉料盤', }, + amsBackup: { + titleOn: 'AMS 備用料盤已開啟。點擊以停用。', + titleOff: 'AMS 備用料盤已關閉。點擊以啟用。', + titleUnknown: '本印表機不支援讀取 AMS 備用料盤狀態。', + toastEnabled: 'AMS 備用料盤已啟用', + toastDisabled: 'AMS 備用料盤已停用', + }, // Filaments section filaments: '耗材', // Camera @@ -1719,6 +1726,7 @@ export default { disableFilamentWarningsDesc: '在列印或加入佇列時不顯示耗材不足警告', preferLowestFilament: '優先使用剩餘最少的耗材', preferLowestFilamentDesc: '當多個料盤匹配時,使用剩餘耗材最少的那個', + preferLowestFilamentBackupNote: '僅當印表機端啟用了 AMS 備用料盤時才生效——否則當選中的料盤耗盡時,印表機無法切換到備用料盤。', trackingModeBuiltIn: '內建庫存', trackingModeBuiltInDesc: '包含 RFID 自動匹配和用量追蹤', trackingModeSpoolmanDesc: '外部耗材管理伺服器', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index d0a04410d..25655dd9f 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -70,6 +70,7 @@ import { Info, Cable, Flame, + Repeat, Snowflake, Gauge, DoorOpen, @@ -691,6 +692,53 @@ 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 +interface AmsBackupBadgeProps { + state: boolean | null; + canToggle: boolean; + pending: boolean; + onToggle: (next: boolean) => void; +} + +function AmsBackupBadge({ state, canToggle, pending, onToggle }: 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 + ? '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 + ? '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'); + } else { + className += 'bg-bambu-dark text-bambu-gray/50 cursor-default'; + title = t('printers.amsBackup.titleUnknown'); + } + + return ( + + ); +} + // Humidity indicator with water drop that fills based on level (Bambu Lab style) // Reference: https://github.com/theicedmango/bambu-humidity interface HumidityIndicatorProps { @@ -2157,6 +2205,21 @@ function PrinterCard({ onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'), }); + // AMS Filament Backup toggle (auto-switch to a backup spool when one runs out). + // Invalidate BOTH printer-status cache keys — the codebase has two conventions + // ('printerStatus' camelCase + 'printer-status' kebab-case used by PrintModal / + // useMultiPrinterFilamentMapping). Hitting only one would leave PrintModal + // showing the old backup state until the user reopens it. + const setAmsBackupMutation = useMutation({ + mutationFn: (enabled: boolean) => api.setAmsFilamentBackup(printer.id, enabled), + onSuccess: (_data, enabled) => { + queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] }); + queryClient.invalidateQueries({ queryKey: ['printer-status', printer.id] }); + showToast(t(enabled ? 'printers.amsBackup.toastEnabled' : 'printers.amsBackup.toastDisabled'), 'success'); + }, + onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'), + }); + // Smart plug control mutations const powerControlMutation = useMutation({ mutationFn: (action: 'on' | 'off') => @@ -4176,6 +4239,12 @@ function PrinterCard({ {t('printers.filaments')} + setAmsBackupMutation.mutate(next)} + />
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index b1186e67e..67464741c 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -4657,6 +4657,9 @@ export function SettingsPage() {

{t('settings.preferLowestFilamentDesc')}

+

+ {t('settings.preferLowestFilamentBackupNote')} +