diff --git a/CHANGELOG.md b/CHANGELOG.md index 415e7d33e..5139b11b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to Bambuddy will be documented in this file. - **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). ### Fixed +- **"Prefer Lowest Remaining Filament" now uses Bambuddy's inventory weight, not just the printer's RFID counter (#1508, reported by @kleinwareio)** — Reporter has an inventory spool cloned to slot 1 and the original (much further used) in slot 4 of the same P1S AMS, with the preference enabled, and the dispatch consistently picked slot 1 (the fresh clone) instead of slot 4 (the original they wanted to finish first). Root cause is the `prefer_lowest` sort in `_match_filaments_to_slots` (`print_scheduler.py`): the sort key reads `f.get("remain", -1)` straight out of `_build_loaded_filaments`, which sources it from MQTT AMS `tray.remain` — the printer firmware's own RFID-decremented value. Two problems with that signal: (a) it's only populated for Bambu RFID spools, so every non-RFID / 3rd-party / user-loaded tray reports `-1` and gets clamped to a sentinel — multiple non-RFID spools then tie in the sort and Python's stable sort collapses to AMS-slot insertion order, so slot 1 always wins; (b) even when set, it's the *printer's* counter, not Bambuddy's `label_weight - weight_used` (internal mode) or Spoolman's `remaining_weight` (Spoolman mode) — the two diverge any time the user re-spools, swaps cardboard, or runs a print outside Bambuddy. The reporter is on internal-inventory mode with non-RFID spools — both failure modes apply, hence slot 1 every time. **Fix**: when a slot is bound to a Bambuddy / Spoolman spool, that inventory record's remaining weight becomes the sort signal. New async helper `_build_inventory_remain_overrides(db, printer_id, loaded)` returns `{global_tray_id: remaining_grams}` for slots with an assignment — internal mode joins `SpoolAssignment` → `Spool` once per dispatch, Spoolman mode joins `SpoolmanSlotAssignment` then fetches each spool through the existing `_spoolman_remaining_grams` (shared with `filament_deficit.py`, parity rule per [[feedback_inventory_modes_parity]]). The new `_prefer_lowest_sort_key` consumes that map alongside the legacy MQTT field with a **two-tier** comparison: inventory-tracked spools always sort BEFORE MQTT-only spools, then ascending by remaining within each tier, then ascending by `ams_id * 4 + tray_id` as the deterministic slot tie-breaker. The tier flag dominates so we never compare grams (inventory) against percent (MQTT) — no unit-conversion contortions. MQTT-only behaviour is preserved exactly: `remain = -1` still maps to the 101 sentinel and slot order still decides on ties, so users who haven't bound any spools see no change. External / VT tray slots are skipped (tracked separately from AMS bindings). Lookup runs only when `prefer_lowest_filament` is enabled — no extra DB hit for users who don't use the preference. **Tests**: 6 new in `TestPreferLowestInventoryOverride` in `test_scheduler_ams_mapping.py` (inventory override beats MQTT remain — the literal reporter scenario with 950 g clone vs 50 g original; zero-grams still sorts first within its tier; inventory tier beats MQTT tier regardless of value; tied inventory grams break to lower slot; no-override falls through to MQTT — regression guard for un-tracked spools; legacy `remain = -1` still sentinel-sorts last when override map is None) + 7 new in `test_scheduler_inventory_remain.py` covering `_build_inventory_remain_overrides` directly (internal mode returns label_weight − weight_used per bound slot; external slots skipped; empty loaded short-circuits; over-consumed spool clamps to 0 g; unbound slots absent from map; Spoolman mode uses `_spoolman_remaining_grams` for parity; Spoolman unreachability silently omits that slot). 102 scheduler + inventory tests green; backend ruff clean. - **X1/H2/P2 live camera no longer fails with `Address already in use` on transitional ffmpeg builds (#1504, reported by @rage03usa, confirmed by @PawseHaxor)** — On a native Ubuntu install with the Jammy-era system ffmpeg, the RTSP live-view path retried indefinitely with `Unable to open RTSP for listening … Address already in use`. Snapshots, the camera diagnostic, and OrcaSlicer all kept working — only live view was broken. Cause: the ffmpeg argv built in `backend/app/api/routes/camera.py` (added in 530a7a46 as part of an RTSP-stability bundle) passed `-timeout 30000000`. That ffmpeg version *deprecated* the original `-timeout` (socket I/O microseconds) and repurposed the name to mean the *RTSP listen-mode incoming-connection timeout* — any non-zero value **implies `-listen`**. ffmpeg then flipped into RTSP server mode and tried to bind the same localhost port Bambuddy's TLS proxy was already listening on, hence EADDRINUSE on every retry (the odd-port pattern @PawseHaxor noticed is coincidence — the ephemeral allocator just picked odd values that run). The reporter's own workaround (drop the option) works but silently loses the socket-level read timeout, so a hung TLS handshake would block past the OS TCP timeout instead of failing fast into the existing reconnect loop. **Why this can't be a one-line literal swap**: ffmpeg has shipped *three* arrangements of this option over time and Bambuddy supports the full range. Pre-deprecation builds: `-timeout` is the socket I/O timeout. Transitional builds (~late-4.x, what the reporter is on): `-timeout` is the broken listen-mode option, `-stimeout` is the replacement. **Modern ffmpeg (5.x / 6.x / 7.x — current Debian 13, Ubuntu 24.04, current Homebrew)**: `-stimeout` was removed entirely and `-timeout` is back to socket I/O. So both literals regress one half of the install base. **Fix**: a new `rtsp_socket_timeout_flag()` helper in `backend/app/services/camera.py` probes `ffmpeg -h demuxer=rtsp` once at first use and picks `-stimeout` when ffmpeg advertises it (transitional window) or `-timeout` otherwise (modern + very old). The result is cached for the process lifetime — ffmpeg won't swap mid-run. The function returns the option name without a leading dash so callers prepend it themselves (no empty-flag formatting bug). Wired into both RTSP ffmpeg call sites — `routes/camera.py` (printer camera) and `services/external_camera.py` (external RTSP) — in lockstep, same TLS-proxy + ffmpeg pattern, same regression. The reporter had tried `-listen_timeout` (doesn't help — we *don't* want listen mode) and `-rw_timeout` (AVIO-level, RTSP demuxer doesn't honour it on its control socket), but no manual swap could be correct for both transitional and modern installs simultaneously. **Tests**: 8 in `test_ffmpeg_rtsp_timeout_flag.py` — 6 unit tests for the probe (picks `-stimeout` when advertised, falls back to `-timeout` on modern, defaults to `-timeout` when ffmpeg missing or probe raises, caches across calls, substring-match guard against false-positives on `-listen_timeout`), 2 parametrised regression guards against either RTSP ffmpeg argv re-hard-coding a literal flag instead of consuming the probe. 37 (probe + existing external-camera) tests green; backend ruff clean. - **SliceModal: process / filament dropdowns now filter by nozzle diameter too, not just printer model (#1325 follow-up #2, reported by @IndividualGhost1905)** — With the @BBL name fallback in place, the reporter saw that an X2D 0.4 selection still mixed 0.2 / 0.6 / 0.8 nozzle process variants into the main list. The fallback's regex stripped any trailing ` nozzle` suffix from both sides before comparing, so `"Bambu Lab X2D 0.4 nozzle"` and `"0.40mm Strength @BBL X2D 0.8 nozzle"` both reduced to `"X2D"` and matched. The bundle path was already nozzle-correct (a `.bbscfg` is scoped to one printer-preset-name including its nozzle, so the bundle-side exact-match was nozzle-aware); only the name fallback needed fixing. **Fix**: `extractPrinterPresetModel` and `extractBblToken` now each return `{ model, nozzle }`. The nozzle is the parsed string ("0.4" / "0.6" / etc.) or `null` when the name has no suffix. `classifyByBambuName` treats a `null` process nozzle as `"0.4"` — Bambu's convention is to omit the suffix on 0.4 (the default) and include it for 0.2 / 0.6 / 0.8, exactly as the reporter described. Both `model` and `nozzle` must compare equal for a `'match'`; differing nozzles fall into the existing "Other printers" group, no new group label needed. If the selected printer preset name has no parseable nozzle (non-Bambu / hand-typed), the matcher degrades to model-only — Bambu printer presets always include nozzle in practice, so this is defensive. **Tests**: 9 new in `slicerPrinterMatch.test.ts` covering the matrix (0.4 printer vs no-suffix / 0.6 / 0.8 process; 0.6 printer vs 0.6 / no-suffix-=-0.4; explicit 0.4-suffix-on-process still matches 0.4 printer; same rule on filament presets; wrong-model dominates over matching-nozzle; no-nozzle printer name degrades to model-only); one existing test reframed (the case that previously asserted a 0.6-nozzle process matched a 0.4 printer — the exact bug — now asserts mismatch). 46 slicerPrinterMatch + 34 SliceModal tests green; frontend build clean. - **Timelapse now attaches to the archive after a backend restart mid-print (#1485 follow-up, reported by @pwostran)** — With the duplicate-archive fix from #1485 in place, a restart mid-print stopped creating ghosts — but the resulting archive came back without its timelapse video (only the finish snapshot was attached). Cause is a side-effect of the #1304 first-push guard: on the first MQTT push after Bambuddy starts (`_previous_gcode_state = None`), `is_new_print` is deliberately False so `on_print_start` doesn't fire — which prevents duplicate archive creation **but also** prevents the timelapse-baseline capture, since both live behind the same callback. At PRINT COMPLETE, `_scan_for_timelapse_with_retries` finds an empty `_timelapse_baselines` for the printer and falls into the "take baseline now" fallback in `main.py`. By that point the printer has already uploaded the in-flight MP4, so the snapshot includes it. Every retry then reports "N files found / no new files since baseline" and the scan gives up. The reporter's support bundle is the smoking gun — pre-reboot baseline of 7 files, post-reboot fallback baseline of 8 files (including the just-uploaded one), 4 retries all unable to see the diff. **Fix**: `bambu_mqtt.py` now fires a sibling `on_print_running_observed` callback inside the "Now tracking RUNNING state" branch when the first-push guard suppresses `on_print_start`. `main.py` wires it to a thin handler that fetches the printer row from DB and calls the existing `_capture_timelapse_baseline_at_start`. The callback only fires the first time we observe RUNNING per session (gated on the same `not self._was_running` branch the timelapse-flag restore already lives in), so a normal print start path is unaffected. The handler is also idempotent: if a baseline already exists for that printer, it returns without touching it. Safe because the printer doesn't upload the timelapse until *after* PRINT COMPLETE, so a baseline captured any time during the in-flight print is still pre-upload — no narrow window. The plumbing (`set_print_running_observed_callback` setter, in-`connect_printer` wrapper, constructor pass-through) mirrors the existing `on_print_start` / `on_print_complete` callback chain in `printer_manager.py`. **Tests**: 7 new in `TestPrintRunningObservedCallback` in `test_bambu_mqtt.py` (fires on first RUNNING after startup, doesn't double up with `on_print_start`, fires only once per session, skips on non-RUNNING / missing file / no-callback-set, payload shape mirrors `on_print_start`); 3 new in a dedicated `test_timelapse_baseline_restart_recovery.py` (handler captures the printer's existing-videos snapshot into `_timelapse_baselines`, skips when a baseline already exists, skips when the printer row was deleted between push and handler). 336 MQTT + print-start + timelapse tests green; backend ruff clean. diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 41784552e..94295043d 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -9,6 +9,7 @@ from pathlib import Path from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from backend.app.core.config import settings from backend.app.core.database import async_session, run_with_retry @@ -18,6 +19,8 @@ from backend.app.models.print_queue import PrintQueueItem from backend.app.models.printer import Printer from backend.app.models.settings import Settings from backend.app.models.smart_plug import SmartPlug +from backend.app.models.spool_assignment import SpoolAssignment +from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment from backend.app.services.bambu_ftp import ( cache_3mf_download, delete_file_async, @@ -855,8 +858,19 @@ class PrintScheduler: # Check if user prefers lowest remaining filament when multiple spools match prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament") + # 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 + # entirely when the preference is off — no behaviour change for + # users who haven't opted in. + inventory_remain_overrides: dict[int, float] | None = None + if prefer_lowest: + inventory_remain_overrides = await self._build_inventory_remain_overrides(db, printer_id, loaded_filaments) + # Compute mapping: match required filaments to available slots - return self._match_filaments_to_slots(filament_reqs, loaded_filaments, prefer_lowest) + return self._match_filaments_to_slots( + filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides + ) def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None: """Build an AMS mapping directly from force-color overrides without a 3MF. @@ -1015,8 +1029,156 @@ class PrintScheduler: except ValueError: return False + async def _build_inventory_remain_overrides( + self, db: AsyncSession, printer_id: int, loaded: list[dict] + ) -> dict[int, float]: + """Return ``{global_tray_id: remaining_grams}`` for AMS slots the user + has bound to an inventory spool — Bambuddy-side or Spoolman-side. + + The MQTT ``remain`` field on a tray is the printer firmware's + RFID-decremented value, which has two limitations the "Prefer Lowest + Remaining Filament" feature has been ignoring (#1508): + + - it's only meaningful for Bambu RFID spools; everything else reports + ``-1`` (then clamped to a sentinel), so multiple non-RFID trays + compare equal and the sort collapses to AMS-slot order — the user + who's curating inventory weights gets the lower-slot pick instead + of the lower-remaining pick; + - even when set, it's the *printer's* counter, not Bambuddy's + ``label_weight - weight_used`` (internal mode) or Spoolman's + ``remaining_weight`` (Spoolman mode) — the two diverge any time the + user re-spools, swaps cardboard, or runs a print outside Bambuddy. + + When the user has bound a spool to a slot, their own inventory + tracking is authoritative; this helper surfaces that value so the + sort can prefer it. Slots without a binding are absent from the + returned map — the caller then falls back to MQTT ``remain`` for + those, preserving the pre-#1508 behaviour for un-tracked spools. + + Returns an empty map on any failure (no inventory bindings, DB + error, Spoolman unreachable). A best-effort lookup; "Prefer Lowest" + is a preference, not a guarantee. + """ + if not loaded: + return {} + # External / virtual-tray slots are tracked separately from AMS — skip + # them so a VT-loaded spool doesn't accidentally inherit a tracked + # AMS binding (the tables use ams_id 254/255 for VT, but the cross + # match is fiddly and out of scope for this fix). + tracked_slots = [(f["ams_id"], f["tray_id"], f["global_tray_id"]) for f in loaded if not f.get("is_external")] + if not tracked_slots: + return {} + + is_spoolman = await self._is_spoolman_mode(db) + overrides: dict[int, float] = {} + + if is_spoolman: + result = await db.execute( + select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id) + ) + assignments = list(result.scalars().all()) + by_slot = {(a.ams_id, a.tray_id): a.spoolman_spool_id for a in assignments} + from backend.app.services.filament_deficit import _spoolman_remaining_grams + + for ams_id, tray_id, gtid in tracked_slots: + spoolman_id = by_slot.get((ams_id, tray_id)) + if spoolman_id is None: + continue + grams = await _spoolman_remaining_grams(spoolman_id) + if grams is not None: + overrides[gtid] = grams + return overrides + + # Internal inventory mode (default). selectinload matches the pattern + # used elsewhere (inventory.py, spoolman.py routes) — a single query + # plus an eager-loaded relationship rather than an explicit join, so + # the row-attribute shape is exactly what those routes already rely on. + result = await db.execute( + select(SpoolAssignment) + .options(selectinload(SpoolAssignment.spool)) + .where(SpoolAssignment.printer_id == printer_id) + ) + assignments = list(result.scalars().all()) + by_slot = {(a.ams_id, a.tray_id): a.spool for a in assignments} + for ams_id, tray_id, gtid in tracked_slots: + spool = by_slot.get((ams_id, tray_id)) + if spool is None: + continue + label = float(spool.label_weight or 0) + used = float(spool.weight_used or 0) + overrides[gtid] = max(0.0, label - used) + return overrides + + @staticmethod + async def _is_spoolman_mode(db: AsyncSession) -> bool: + """Mirror of ``filament_deficit._is_spoolman_mode`` — kept private + here to avoid making this module import-dependent on that private + helper's signature.""" + try: + from backend.app.api.routes.settings import get_setting + + v = await get_setting(db, "spoolman_enabled") + return bool(v) and v.lower() == "true" + except Exception: + return False + + @staticmethod + def _slot_priority(ams_id: int | None, tray_id: int | None) -> int: + """Deterministic slot-position tie-breaker for the prefer-lowest sort. + + Three bands, matched to the emission order in ``_build_loaded_filaments`` + so a tied sort produces the same physical-position order the pre-#1508 + stable sort did (preserves the regression-free baseline): + + - Regular AMS (``ams_id`` 0..7): ``ams_id * 4 + tray_id`` → 0..31 + - AMS-HT (``ams_id`` >= 128, single tray): ``1000 + (ams_id - 128) * 4`` + - External / VT (``ams_id`` < 0, or ``None``): ``10_000`` + + Banding ensures regular AMS < AMS-HT < external on ties, regardless of + what the raw ``ams_id`` happens to be (in particular, ``ams_id = -1`` + for VT must NOT sort to a negative number or it would beat AMS slot 0). + """ + if ams_id is None or ams_id < 0: + return 10_000 + if ams_id >= 128: + return 1_000 + (ams_id - 128) * 4 + (tray_id or 0) + return ams_id * 4 + (tray_id or 0) + + @staticmethod + def _prefer_lowest_sort_key(f: dict, overrides: dict[int, float] | None) -> tuple[int, float, int]: + """Sort key for the "Prefer Lowest Remaining Filament" preference. + + Two-tier ordering: inventory-tracked spools always sort BEFORE + non-tracked spools (the user has told us they care about these + specifically), then ascending by remaining within each tier, then + ascending by AMS slot position as the deterministic tie-breaker. + + Tiers are flagged by the first tuple element (0 = inventory-tracked, + 1 = MQTT-only / unknown). Cross-tier value comparisons never run + because the tier flag dominates — which is what lets us mix grams + (inventory) and percent (MQTT) without a unit conversion. + + Within the MQTT tier ``remain = -1`` (unknown) is mapped to 101 so + spools the printer DOES know something about sort ahead of those + it knows nothing about — preserves pre-#1508 behaviour for the + no-inventory-binding case. + + Slot tie-breaker via ``_slot_priority`` so regular AMS < AMS-HT < + external on ties, matching the legacy emission-order stable sort. + """ + gtid = f.get("global_tray_id") + slot_order = PrintScheduler._slot_priority(f.get("ams_id"), f.get("tray_id")) + if overrides and gtid in overrides: + return (0, overrides[gtid], slot_order) + remain = f.get("remain", -1) + return (1, float(remain) if remain is not None and remain >= 0 else 101.0, slot_order) + def _match_filaments_to_slots( - self, required: list[dict], loaded: list[dict], prefer_lowest: bool = False + self, + required: list[dict], + loaded: list[dict], + prefer_lowest: bool = False, + inventory_remain_overrides: dict[int, float] | None = None, ) -> list[int] | None: """Match required filaments to loaded filaments and build AMS mapping. @@ -1063,9 +1225,11 @@ class PrintScheduler: if req_nozzle_id is not None: available = [f for f in available if f.get("extruder_id") == req_nozzle_id] - # Sort by remaining filament (ascending) so lowest-remain spool wins .find() + # Sort by remaining filament (ascending) so lowest-remain spool wins .find(). + # Inventory-tracked spools sort before MQTT-only ones (#1508); see + # _prefer_lowest_sort_key for the full rationale. if prefer_lowest: - available.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101) + available.sort(key=lambda f: self._prefer_lowest_sort_key(f, inventory_remain_overrides)) # Check if tray_info_idx is unique among available trays if req_tray_info_idx: @@ -1084,7 +1248,7 @@ class PrintScheduler: f"using color matching among trays: {[f['global_tray_id'] for f in idx_matches]}" ) if prefer_lowest: - idx_matches.sort(key=lambda f: f.get("remain", -1) if f.get("remain", -1) >= 0 else 101) + idx_matches.sort(key=lambda f: self._prefer_lowest_sort_key(f, inventory_remain_overrides)) # Use color matching within this subset for f in idx_matches: f_color = f.get("color", "") diff --git a/backend/tests/unit/test_scheduler_ams_mapping.py b/backend/tests/unit/test_scheduler_ams_mapping.py index d0445cfb8..85d7096c1 100644 --- a/backend/tests/unit/test_scheduler_ams_mapping.py +++ b/backend/tests/unit/test_scheduler_ams_mapping.py @@ -505,6 +505,270 @@ class TestPreferLowestFilament: assert result == [254] # Should pick external spool (10%) over AMS (80%) +class TestPreferLowestInventoryOverride: + """Tests for the #1508 inventory-aware sort: when the user has bound a + Bambuddy inventory spool to an AMS slot, that spool's remaining weight + becomes the sort signal instead of the MQTT ``remain`` percentage. + + The fix is two-tier: inventory-tracked spools always sort before + MQTT-only ones, then ascending by remaining within each tier, then + ascending by AMS slot position. See + ``print_scheduler._prefer_lowest_sort_key`` for the rationale. + """ + + @pytest.fixture + def scheduler(self): + return PrintScheduler() + + def test_inventory_override_beats_mqtt_remain(self, scheduler): + """Slot 4's inventory shows 50 g remaining; slot 1's clone has 950 g. + MQTT ``remain`` is -1 for both (non-RFID spools), so without the + override the sort collapses to AMS-slot order and slot 1 wins. + With the override slot 4 (the original, nearly empty) wins. This is + the literal reporter scenario in #1508. + """ + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "remain": -1, + }, + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 3, + "global_tray_id": 3, + "remain": -1, + }, + ] + # Slot 1 (gtid 0) is the fresh clone at 950 g; slot 4 (gtid 3) is the + # nearly-empty original at 50 g. + overrides = {0: 950.0, 3: 50.0} + result = scheduler._match_filaments_to_slots( + required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides + ) + assert result == [3] + + def test_inventory_override_with_zero_grams_still_wins(self, scheduler): + """An inventory-tracked spool at 0 g must still sort first within + its tier — the user wants to finish what's left (or be told there's + a deficit) rather than skip to the fresh one. The clamp-to-101 + legacy logic only fires on negative values, so 0 g stays 0.0. + """ + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "remain": -1, + }, + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 1, + "global_tray_id": 1, + "remain": -1, + }, + ] + overrides = {0: 500.0, 1: 0.0} + result = scheduler._match_filaments_to_slots( + required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides + ) + assert result == [1] + + def test_inventory_tier_beats_mqtt_tier_regardless_of_value(self, scheduler): + """Mixed mode: one slot is inventory-tracked at 800 g (high), the + other has only a MQTT remain of 10 (very low). The inventory tier + wins because the user has explicitly told us to manage that slot — + unit-mixing across tiers is intentional and resolved by the tier + flag, not value comparison. + """ + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "remain": 10, + }, + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 1, + "global_tray_id": 1, + "remain": -1, + }, + ] + overrides = {1: 800.0} # only slot 2 has an inventory binding + result = scheduler._match_filaments_to_slots( + required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides + ) + assert result == [1] + + def test_two_tracked_spools_tied_at_same_grams_lower_slot_wins(self, scheduler): + """Two inventory-tracked spools with genuinely equal remaining + weight — slot tie-breaker decides. Lower AMS slot wins to match + the user's mental model of "use the lower slot first when equal." + """ + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 1, + "global_tray_id": 1, + "remain": -1, + }, + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "remain": -1, + }, + ] + overrides = {0: 500.0, 1: 500.0} + result = scheduler._match_filaments_to_slots( + required, loaded, prefer_lowest=True, inventory_remain_overrides=overrides + ) + assert result == [0] + + def test_no_override_falls_back_to_mqtt_remain(self, scheduler): + """When no slots are inventory-bound the override map is empty + and behaviour is identical to the pre-#1508 MQTT-only sort. + Regression guard for the un-tracked-spool case. + """ + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "remain": 80, + }, + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 1, + "global_tray_id": 1, + "remain": 30, + }, + ] + result = scheduler._match_filaments_to_slots( + required, loaded, prefer_lowest=True, inventory_remain_overrides={} + ) + assert result == [1] + + def test_no_override_unknown_remain_sorts_after_known(self, scheduler): + """In the no-binding case, ``remain = -1`` (non-RFID, unknown) must + still sort *after* a slot the printer knows something about — the + legacy sentinel-101 behaviour is preserved. + """ + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "remain": -1, + }, + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 1, + "global_tray_id": 1, + "remain": 50, + }, + ] + result = scheduler._match_filaments_to_slots( + required, loaded, prefer_lowest=True, inventory_remain_overrides=None + ) + assert result == [1] + + def test_external_with_negative_ams_id_does_not_outrank_ams_on_tie(self, scheduler): + """``_build_loaded_filaments`` emits external/VT trays with + ``ams_id = -1``. A naive ``ams_id * 4 + tray_id`` slot-priority + formula would compute -4 for an external and 0 for AMS slot 0 — + flipping the legacy stable-sort baseline (which kept AMS first + because it's emitted before externals). When ``remain`` ties + between the two, AMS slot 0 must still win. + """ + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "remain": -1, + "is_external": False, + }, + { + "type": "PLA", + "color": "#FF0000", + "ams_id": -1, + "tray_id": 0, + "global_tray_id": 254, + "remain": -1, + "is_external": True, + }, + ] + result = scheduler._match_filaments_to_slots( + required, loaded, prefer_lowest=True, inventory_remain_overrides=None + ) + assert result == [0] + + def test_ams_ht_does_not_outrank_regular_ams_on_tie(self, scheduler): + """AMS-HT units use ``ams_id`` >= 128 with a single tray. On a tied + ``remain`` value, regular AMS slot 0 (slot_priority 0) must beat + AMS-HT (slot_priority 1000+). + """ + required = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}] + loaded = [ + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 0, + "tray_id": 0, + "global_tray_id": 0, + "remain": 50, + }, + { + "type": "PLA", + "color": "#FF0000", + "ams_id": 128, + "tray_id": 0, + "global_tray_id": 128, + "remain": 50, + }, + ] + result = scheduler._match_filaments_to_slots( + required, loaded, prefer_lowest=True, inventory_remain_overrides=None + ) + assert result == [0] + + class TestBuildLoadedFilamentsTrayInfoIdx: """Test tray_info_idx extraction in _build_loaded_filaments.""" diff --git a/backend/tests/unit/test_scheduler_inventory_remain.py b/backend/tests/unit/test_scheduler_inventory_remain.py new file mode 100644 index 000000000..5fa77b953 --- /dev/null +++ b/backend/tests/unit/test_scheduler_inventory_remain.py @@ -0,0 +1,194 @@ +"""Tests for the inventory-remain override builder in print_scheduler (#1508). + +The MQTT ``remain`` field on an AMS tray is the printer firmware's +RFID-tracked value, which is ``-1`` for non-Bambu spools (and even when +set diverges from Bambuddy's inventory). When the user has bound an +inventory spool to an AMS slot, that inventory record's +``label_weight - weight_used`` (or Spoolman's ``remaining_weight``) is +the authoritative remaining-weight signal. These tests verify +``_build_inventory_remain_overrides`` surfaces those values keyed by +``global_tray_id`` so the "Prefer Lowest Remaining Filament" sort can +consume them. +""" + +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 _make_async_session_returning(rows: list): + """Build a stub AsyncSession whose .execute() returns an object whose + .all() (and .scalars().all()) yield ``rows``.""" + result = MagicMock() + result.all.return_value = rows + scalars = MagicMock() + scalars.all.return_value = rows + result.scalars.return_value = scalars + db = MagicMock() + db.execute = AsyncMock(return_value=result) + return db + + +class TestInternalInventoryOverrides: + @pytest.mark.asyncio + async def test_returns_remaining_grams_for_bound_slots(self, scheduler): + """Two slots bound; both come back keyed by global_tray_id with the + correct ``label_weight - weight_used`` in grams. This is the + reporter scenario in #1508: slot 1 has a 950 g clone, slot 4 has + a 50 g original — the sort can now actually pick the 50 g spool. + + The override builder uses ``select(SpoolAssignment).options( + selectinload(SpoolAssignment.spool))`` (matching the rest of the + codebase), so the rows it iterates expose ``.ams_id``, ``.tray_id`` + and ``.spool`` directly — the test stubs the same shape. + """ + spool_a = SimpleNamespace(label_weight=1000, weight_used=50) # 950 g remaining + spool_b = SimpleNamespace(label_weight=1000, weight_used=950) # 50 g remaining + rows = [ + SimpleNamespace(ams_id=0, tray_id=0, spool=spool_a), + SimpleNamespace(ams_id=0, tray_id=3, spool=spool_b), + ] + loaded = [ + {"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}, + ] + db = _make_async_session_returning(rows) + with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)): + out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded) + assert out == {0: 950.0, 3: 50.0} + + @pytest.mark.asyncio + async def test_skips_external_slots(self, scheduler): + """VT / external slots are tracked separately from AMS inventory + bindings — the override builder must not assign them an inventory + remaining value even if (somehow) an assignment row exists. + """ + loaded = [ + {"ams_id": -1, "tray_id": 0, "global_tray_id": 254, "is_external": True}, + ] + db = _make_async_session_returning([]) + with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)): + out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded) + # DB shouldn't even be queried — nothing AMS-side to look up. + db.execute.assert_not_called() + assert out == {} + + @pytest.mark.asyncio + async def test_empty_loaded_returns_empty(self, scheduler): + """No loaded filaments → no overrides. The scheduler short-circuits + before this is called in practice, but the function must be + defensive — it's used in any prefer_lowest dispatch path.""" + db = _make_async_session_returning([]) + with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)): + out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=[]) + assert out == {} + db.execute.assert_not_called() + + @pytest.mark.asyncio + async def test_negative_remaining_clamped_to_zero(self, scheduler): + """An over-consumed spool (weight_used > label_weight) shouldn't + produce a negative grams value — clamped to 0 so the sort treats + it as fully empty rather than "more empty than zero." + """ + spool = SimpleNamespace(label_weight=1000, weight_used=1100) + rows = [ + SimpleNamespace(ams_id=0, tray_id=0, spool=spool), + ] + loaded = [{"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}] + db = _make_async_session_returning(rows) + with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)): + out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded) + assert out == {0: 0.0} + + @pytest.mark.asyncio + async def test_slot_without_binding_absent_from_overrides(self, scheduler): + """A slot that has loaded filament but no inventory binding must + not appear in the override map — the sort then falls back to MQTT + ``remain`` for that one slot, preserving pre-#1508 behaviour. + """ + rows = [ + SimpleNamespace( + ams_id=0, + tray_id=0, + spool=SimpleNamespace(label_weight=1000, weight_used=100), + ), + ] + loaded = [ + {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}, + {"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False}, + ] + db = _make_async_session_returning(rows) + with patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=False)): + out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded) + assert out == {0: 900.0} + assert 1 not in out + + +class TestSpoolmanModeOverrides: + @pytest.mark.asyncio + async def test_spoolman_remaining_grams_used_when_available(self, scheduler): + """Spoolman mode: each bound slot's spoolman_spool_id is fetched + through ``_spoolman_remaining_grams``; the result is the same + global-tray-id-keyed grams map. Parity rule with internal mode + (feedback_inventory_modes_parity). + """ + rows = [ + SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42), + SimpleNamespace(printer_id=1, ams_id=0, tray_id=2, spoolman_spool_id=99), + ] + loaded = [ + {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}, + {"ams_id": 0, "tray_id": 2, "global_tray_id": 2, "is_external": False}, + ] + db = _make_async_session_returning(rows) + + async def _fake_grams(spool_id: int): + return {42: 720.0, 99: 80.0}[spool_id] + + with ( + patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)), + patch( + "backend.app.services.filament_deficit._spoolman_remaining_grams", + new=AsyncMock(side_effect=_fake_grams), + ), + ): + out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded) + assert out == {0: 720.0, 2: 80.0} + + @pytest.mark.asyncio + async def test_spoolman_unreachable_skips_silently(self, scheduler): + """If Spoolman is unreachable for one spool, ``_spoolman_remaining_grams`` + returns None and that slot is omitted from the override map — + sorting then falls back to MQTT remain for that slot only. + """ + rows = [ + SimpleNamespace(printer_id=1, ams_id=0, tray_id=0, spoolman_spool_id=42), + SimpleNamespace(printer_id=1, ams_id=0, tray_id=1, spoolman_spool_id=99), + ] + loaded = [ + {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False}, + {"ams_id": 0, "tray_id": 1, "global_tray_id": 1, "is_external": False}, + ] + db = _make_async_session_returning(rows) + + async def _fake_grams(spool_id: int): + return 500.0 if spool_id == 42 else None + + with ( + patch.object(PrintScheduler, "_is_spoolman_mode", new=AsyncMock(return_value=True)), + patch( + "backend.app.services.filament_deficit._spoolman_remaining_grams", + new=AsyncMock(side_effect=_fake_grams), + ), + ): + out = await scheduler._build_inventory_remain_overrides(db, printer_id=1, loaded=loaded) + assert out == {0: 500.0} + assert 1 not in out