Browse Source

Stop waking printers that cannot print the queued job (issue #2876)

The queue's smart-plug step chose a printer to switch on by model alone.
With a class-targeted job and every matching printer off, it walked the
farm in printer-ID order, woke the first machine with an Auto On plug,
and only then read the loaded filament -- so a job for a colour loaded at
the far end of the farm woke every earlier printer in turn and left each
one running until its own auto-power-off timer expired.

The colours were known the whole time. A printer keeps its last reported
AMS and external-spool trays after the power goes; mark_power_off blanks
connected and state and leaves raw_data alone. The wake step now asks the
same three questions the matcher asks a live printer -- required types,
forced colours, preferred colours -- of that reading, and passes over a
printer it rules out. A printer with no reading at all is still woken:
never having heard is not the same as nothing being loaded.

The matcher reports such a printer as needing filament rather than as
offline, so the waiting reason explains why nothing was switched on.

The manager now keeps a printer's last tray reading when it drops the
client, and the queue falls back to that. _power_on_and_wait calls
connect_printer in a retry loop, so an attempt that timed out used to
erase the reading the next one depends on. The record is kept beside the
clients, not inside one: the AMS merge is additive, so feeding it back
into live status would merge an unplugged AMS unit back in for good.
maziggy 2 weeks ago
parent
commit
dd50c51c1b

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **A queued job switched on printers that could never have printed it** — When no printer of the target model was available, the queue powered one on via its smart plug, but chose it on model alone: it walked the farm in printer-ID order, woke the first machine with an Auto On plug, and only then discovered the loaded filament was the wrong colour. A job for a colour loaded at the far end of the farm therefore woke every earlier printer in turn and left each one running until its own auto-power-off timer expired. The colours were known the whole time — a printer keeps its last reported AMS and external-spool trays after the power goes — so the wake step now asks the same three questions the matcher asks a live printer (required types, forced colours, preferred colours) and passes over a printer whose last known filament cannot satisfy the job. A printer Bambuddy has never heard from is still woken: no reading is not the same as no filament. Reconnecting a printer also no longer discards that reading, so a power-on attempt that times out stops erasing what the next attempt needs.
 - **Archive metadata could describe a plate that was never printed** — The layer height on the archive card and in the library's file details came only from the 3MF's `project_settings.config`, and the plate G-code beside it was read for the layer count alone — the first `.gcode` entry in the zip, whatever plate the archive was actually for. A multi-plate export therefore reported plate 1's layer count even when plate 3 ran, and nothing ever cross-checked the layer height against the plate that produced the print. Both now come from the printed plate: its G-code is read (64 KB, enough to reach the config block that carries `layer_height` 14–25 KB in, where 4 KB only ever reached the header), and its value wins over the project's where the two disagree. Source 3MFs, which carry no G-code, keep the project value exactly as before.
 - **Slicing a file could ignore the process preset you picked** — Bambuddy carries a designer's own process deviations across a re-slice (#2622) and pre-ticked all of them except the machine-coupled ones. `layer_height` is one that MakerWorld projects routinely carry, so picking "0.08mm High Quality" for a file whose designer had moved layer height to 0.2 sliced at 0.2 while the dropdown still read 0.08 — the same 0.2 the settings panel showed, tagged "from file". Layer height and first layer height are now treated like the machine-coupled keys: still offered, never pre-selected, and their badge in the settings panel names the conflict and shows the preset's own value beside the file's, so ticking one is a deliberate choice.
 - **Statistics forgot the name of a printer that was deleted with its history kept (#2873, reported by @rembomy)** — Prints by Printer, the per-printer success breakdown, the time-accuracy list and Failures by Printer all resolved the name against the printers that exist right now, so deleting a printer and choosing to keep its prints turned "Ultron" into "Printer 1" everywhere. The runs themselves already recorded the name they printed on, and that is what those breakdowns fall back to now: the last name the id was known by, for as long as its prints are kept. A printer that still exists is named from its own record as before, so a rename shows up immediately rather than after the next print. Covered by backend and frontend regression tests.

+ 191 - 49
backend/app/services/print_scheduler.py

@@ -309,6 +309,44 @@ def _sliced_for_model(archive, library_file) -> str | None:
     return None
 
 
+def _filament_constraints(candidate: _ModelCandidate) -> tuple[list[str] | None, list[dict] | None]:
+    """The filament a candidate needs, as ``(types, overrides)``.
+
+    Both columns are JSON text written by the slicer step. Malformed content is
+    treated as no constraint rather than as an error: a job whose overrides
+    cannot be parsed still prints, it just gets no filament-based narrowing.
+
+    Overrides carry their own types, so the returned type list is the union of
+    the two — an override on one slot must not drop the requirements of the
+    slots it says nothing about.
+
+    Shared by the matcher and by the smart-plug wake step so both ask a printer
+    for the same filament (#2876). Waking a printer the matcher would then
+    reject on colour is the bug this exists to prevent.
+    """
+    required_types = None
+    if candidate.required_filament_types:
+        try:
+            required_types = json.loads(candidate.required_filament_types)
+        except json.JSONDecodeError:
+            pass
+
+    filament_overrides = None
+    if candidate.filament_overrides:
+        try:
+            filament_overrides = json.loads(candidate.filament_overrides)
+        except json.JSONDecodeError:
+            pass
+
+    effective_types = required_types
+    if filament_overrides:
+        override_types = sorted({o["type"] for o in filament_overrides if "type" in o})
+        if override_types:
+            effective_types = sorted(set(required_types or []) | set(override_types))
+
+    return effective_types, filament_overrides
+
+
 def _candidates_for(item: PrintQueueItem) -> list[_ModelCandidate]:
     """Candidate files for ``item``, best first.
 
@@ -1300,29 +1338,7 @@ class PrintScheduler:
                         )
 
                     for candidate in candidates:
-                        # Parse required filament types if present
-                        required_types = None
-                        if candidate.required_filament_types:
-                            try:
-                                required_types = json.loads(candidate.required_filament_types)
-                            except json.JSONDecodeError:
-                                pass  # Ignore malformed filament types; treat as no constraint
-
-                        # Parse filament overrides if present
-                        filament_overrides = None
-                        if candidate.filament_overrides:
-                            try:
-                                filament_overrides = json.loads(candidate.filament_overrides)
-                            except json.JSONDecodeError:
-                                pass
-
-                        # If overrides exist, use override types for validation instead
-                        effective_types = required_types
-                        if filament_overrides:
-                            override_types = sorted({o["type"] for o in filament_overrides if "type" in o})
-                            if override_types:
-                                # Merge: keep original types for non-overridden slots, add override types
-                                effective_types = sorted(set(required_types or []) | set(override_types))
+                        effective_types, filament_overrides = _filament_constraints(candidate)
 
                         # Cross-model safety gate (#2578): never hand a 3MF sliced
                         # for an incompatible model to a printer, no matter how the
@@ -1987,10 +2003,15 @@ class PrintScheduler:
         from "we tried and it did not come up" (only ``attempted_id`` — the
         boot timeout has already been spent).
 
-        Deliberately does NOT go on to match the job: AMS trays arrive with the
-        first status push after connect, so a filament check against a printer
-        that booted seconds ago can reject the printer we just woke. The next
-        queue pass matches it with live state.
+        A printer whose last known trays cannot satisfy the job is passed over
+        rather than woken (#2876): the colours are readable while it is off, so
+        switching a farm on one machine at a time to discover them wakes
+        printers that could never have taken the job.
+
+        Deliberately does NOT go on to match the job once a printer is up: AMS
+        trays arrive with the first status push after connect, so a filament
+        check against a printer that booted seconds ago can reject the printer
+        we just woke. The next queue pass matches it with live state.
 
         At most one printer per pass. Each wake blocks the queue loop for the
         boot wait, and a queue of ten class-targeted jobs must not switch on
@@ -1999,6 +2020,7 @@ class PrintScheduler:
         for candidate in candidates:
             if not candidate.target_model:
                 continue
+            required_types, filament_overrides = _filament_constraints(candidate)
             printers = await self._printers_for_model(db, candidate.target_model, target_location)
             for printer in sorted(printers, key=lambda p: p.id):
                 if printer.id in exclude_ids or printer.id not in wakeable_ids:
@@ -2023,6 +2045,16 @@ class PrintScheduler:
                     )
                     continue
 
+                shortfall = self._cached_filament_shortfall(printer.id, required_types, filament_overrides)
+                if shortfall:
+                    logger.info(
+                        "Not powering on printer %s for a %s job: last known filament cannot satisfy it (needs %s)",
+                        printer.id,
+                        candidate.target_model,
+                        ", ".join(shortfall),
+                    )
+                    continue
+
                 plugs = await self._get_smart_plugs(db, printer.id)
                 auto_on_plugs = [p for p in plugs if p.auto_on and p.enabled]
                 if not auto_on_plugs:
@@ -2124,7 +2156,15 @@ class PrintScheduler:
             is_idle = self._is_printer_idle(printer.id, require_plate_clear) if is_connected else False
 
             if not is_connected:
-                if wakeable_ids is not None and printer.id not in wakeable_ids:
+                # An offline printer whose last known filament cannot run this
+                # job is reported as needing filament rather than as offline
+                # (#2876). It is also the printer the smart-plug step will now
+                # decline to switch on, and "Offline:" on its own would leave
+                # that decision looking like nothing happening at all.
+                shortfall = self._cached_filament_shortfall(printer.id, required_filament_types, filament_overrides)
+                if shortfall:
+                    printers_missing_filament.append((printer.name, shortfall))
+                elif wakeable_ids is not None and printer.id not in wakeable_ids:
                     printers_offline_no_plug.append(printer.name)
                 else:
                     printers_offline.append(printer.name)
@@ -2214,10 +2254,14 @@ class PrintScheduler:
                 # but only if there are no busy printers that DO have the matching color.
                 # If a printer has the right color but is busy, surface "Busy" instead so
                 # the user knows the job will start automatically once that printer is free.
-                if not printers_busy:
+                # Same for a printer that is merely offline: Bambuddy switches that one on
+                # by itself, so the job is not actually waiting on anybody to change a
+                # spool (#2876 — offline printers reach this list now that a switched-off
+                # printer's own filament is read).
+                if not printers_busy and not printers_offline:
                     all_missing = sorted({c for _, cols in printers_missing_filament for c in cols})
                     return None, f"No matching material/color. Waiting on {', '.join(all_missing)}"
-                # else: fall through — printers_busy will be appended below
+                # else: fall through — the self-resolving entries are appended below
             else:
                 names_and_missing = [
                     f"{name} (needs {', '.join(missing)})" for name, missing in printers_missing_filament
@@ -2246,7 +2290,9 @@ class PrintScheduler:
         parts = [p.strip() for p in waiting_reason.split(" | ")]
         return all(p.startswith("Busy:") for p in parts)
 
-    def _get_missing_force_color_slots(self, printer_id: int, force_overrides: list[dict]) -> list[str]:
+    def _get_missing_force_color_slots(
+        self, printer_id: int, force_overrides: list[dict], raw_data: dict | None = None
+    ) -> list[str]:
         """Return descriptive strings for force_color_match slots not satisfied by the printer.
 
         Each entry in ``force_overrides`` must have ``type`` and ``color`` fields and is expected
@@ -2264,19 +2310,21 @@ class PrintScheduler:
         Returns:
             List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
         """
-        status = printer_manager.get_status(printer_id)
-        if not status:
-            return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
+        if raw_data is None:
+            status = printer_manager.get_status(printer_id)
+            if not status:
+                return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
+            raw_data = status.raw_data
 
         # Build loaded (type, colour, tray_info_idx) triples from AMS and external spool.
         loaded: list[tuple[str, str, str]] = []
-        for ams_unit in status.raw_data.get("ams", []):
+        for ams_unit in raw_data.get("ams", []):
             for tray in ams_unit.get("tray", []):
                 tray_type = tray.get("tray_type")
                 if tray_type:
                     color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
                     loaded.append((canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or ""))
-        for vt in status.raw_data.get("vt_tray") or []:
+        for vt in raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
@@ -2296,7 +2344,9 @@ class PrintScheduler:
                 missing.append(f"{o_type} ({color_label})")
         return missing
 
-    def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
+    def _get_missing_filament_types(
+        self, printer_id: int, required_types: list[str], raw_data: dict | None = None
+    ) -> list[str]:
         """Get the list of required filament types that are not loaded on the printer.
 
         Args:
@@ -2306,16 +2356,18 @@ class PrintScheduler:
         Returns:
             List of missing filament types (empty if all are loaded)
         """
-        status = printer_manager.get_status(printer_id)
-        if not status:
-            return required_types  # Can't determine, assume all missing
+        if raw_data is None:
+            status = printer_manager.get_status(printer_id)
+            if not status:
+                return required_types  # Can't determine, assume all missing
+            raw_data = status.raw_data
 
         # Collect all filament types loaded on this printer (AMS units + external spool)
         # Use canonical types so equivalence groups (e.g. PA-CF/PA12-CF/PAHT-CF) match.
         loaded_types: set[str] = set()
 
         # Check AMS units (stored in raw_data["ams"])
-        ams_data = status.raw_data.get("ams", [])
+        ams_data = raw_data.get("ams", [])
         if ams_data:
             for ams_unit in ams_data:
                 for tray in ams_unit.get("tray", []):
@@ -2324,7 +2376,7 @@ class PrintScheduler:
                         loaded_types.add(canonical_filament_type(tray_type))
 
         # Check external spool(s) (virtual tray, stored in raw_data["vt_tray"] as list)
-        for vt in status.raw_data.get("vt_tray") or []:
+        for vt in raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
                 loaded_types.add(canonical_filament_type(vt_type))
@@ -2337,25 +2389,32 @@ class PrintScheduler:
 
         return missing
 
-    def _count_override_color_matches(self, printer_id: int, overrides: list[dict]) -> int:
+    def _count_override_color_matches(
+        self, printer_id: int, overrides: list[dict], raw_data: dict | None = None
+    ) -> int:
         """Count how many filament overrides have an exact color match on the printer.
 
         Used to prefer printers that already have the desired override colors loaded.
         """
-        status = printer_manager.get_status(printer_id)
-        if not status:
-            return 0
+        if raw_data is None:
+            status = printer_manager.get_status(printer_id)
+            if not status:
+                return 0
+            raw_data = status.raw_data
 
         # Collect loaded filaments' type+color pairs
         loaded: set[tuple[str, str]] = set()
-        for ams_unit in status.raw_data.get("ams", []):
+        for ams_unit in raw_data.get("ams", []):
             for tray in ams_unit.get("tray", []):
                 tray_type = tray.get("tray_type")
-                tray_color = tray.get("tray_color", "")
+                # `or ""`, not a dict default: a slot can carry the key with a
+                # null value, and this now runs against switched-off printers
+                # too, where nobody is watching for the AttributeError.
+                tray_color = tray.get("tray_color") or ""
                 if tray_type:
                     color_norm = tray_color.replace("#", "").lower()[:6]
                     loaded.add((tray_type.upper(), color_norm))
-        for vt in status.raw_data.get("vt_tray") or []:
+        for vt in raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
@@ -2369,6 +2428,89 @@ class PrintScheduler:
                 matches += 1
         return matches
 
+    @staticmethod
+    def _tray_reading(printer_id: int) -> dict:
+        """The best tray reading available for a printer that is not printing.
+
+        Live status first: a printer keeps its last status after the power
+        goes, because ``mark_power_off`` blanks ``connected`` and ``state`` and
+        leaves ``raw_data`` alone. The manager's own record is the fallback,
+        for when the client itself has been dropped and taken its status with
+        it — which is what every power-on attempt does.
+
+        An empty result means "we have never heard", not "nothing is loaded":
+        the two are indistinguishable from here, and only the second would be
+        safe to act on.
+        """
+        status = printer_manager.get_status(printer_id)
+        raw = (status.raw_data if status else None) or {}
+        for ams_unit in raw.get("ams") or []:
+            if any(tray.get("tray_type") for tray in ams_unit.get("tray", [])):
+                return raw
+        if any(vt.get("tray_type") for vt in raw.get("vt_tray") or []):
+            return raw
+        return printer_manager.last_known_trays(printer_id)
+
+    def _cached_filament_shortfall(
+        self,
+        printer_id: int,
+        required_types: list[str] | None,
+        filament_overrides: list[dict] | None,
+    ) -> list[str]:
+        """What a switched-off printer's last known filament cannot provide (#2876).
+
+        The smart-plug wake step used to consider only the model, so a job for a
+        colour loaded on the last printer in ID order switched on every earlier
+        one in turn, evaluated it, rejected it on colour and left it running.
+        Bambuddy knew those colours the whole time. This asks the same three
+        questions the matcher asks a live printer — required types, forced
+        colours, preferred colours — of the trays it last reported, and returns
+        the answers in the same shape the "Waiting for filament" reason uses.
+
+        Empty means the printer may still be able to take the job.
+
+        Fails open, and deliberately: with no tray reading (never connected
+        since Bambuddy started, or the cache dropped by a reconnect) this
+        returns nothing to report and the printer is treated as it was before.
+        A farm restarted while its printers were off must not conclude that
+        none of them can print.
+        """
+        if not required_types and not filament_overrides:
+            return []
+
+        raw_data = self._tray_reading(printer_id)
+        if not raw_data:
+            return []
+
+        force_overrides = [o for o in (filament_overrides or []) if o.get("force_color_match")]
+        pref_overrides = [o for o in (filament_overrides or []) if not o.get("force_color_match")]
+
+        if required_types:
+            missing = self._get_missing_filament_types(printer_id, required_types, raw_data)
+            if missing:
+                # Same enrichment the live path applies: a bare "PLA" is not
+                # much help when what is missing is a particular PLA.
+                force_color_map = {
+                    (o.get("type") or "").upper(): o.get("color_name") or o.get("color", "?") for o in force_overrides
+                }
+                return [
+                    f"{t} ({force_color_map[t_upper]})" if (t_upper := t.upper()) in force_color_map else t
+                    for t in missing
+                ]
+
+        if force_overrides:
+            missing_colors = self._get_missing_force_color_slots(printer_id, force_overrides, raw_data)
+            if missing_colors:
+                return missing_colors
+
+        # Preference overrides read as a preference but the matcher treats zero
+        # matches as a skip, so a printer with none of the wanted colours is
+        # rejected there too. Waking it would only produce that same rejection.
+        if pref_overrides and self._count_override_color_matches(printer_id, pref_overrides, raw_data) == 0:
+            return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in pref_overrides]
+
+        return []
+
     def _resolve_variant(self, item: PrintQueueItem, candidate: _ModelCandidate) -> None:
         """Fold the winning candidate's file and settings onto the queue row (#671).
 

+ 31 - 0
backend/app/services/printer_manager.py

@@ -398,6 +398,13 @@ class PrinterManager:
         self._clients: dict[int, BambuMQTTClient] = {}
         self._models: dict[int, str | None] = {}  # Cache printer models for feature detection
         self._printer_info: dict[int, PrinterInfo] = {}  # Cache printer name/serial for callbacks
+        # Last AMS / external-spool reading of a printer whose client has been
+        # dropped, so the queue can still tell which machine holds which colour
+        # (#2876). Deliberately outside the client's own state: it answers
+        # "what did this printer last have loaded", not "what is it reporting
+        # now", and the two must not be confused by anything that displays or
+        # merges live status.
+        self._last_trays: dict[int, dict] = {}
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_complete: Callable[[int, dict], None] | None = None
         self._on_print_running_observed: Callable[[int, dict], None] | None = None
@@ -719,6 +726,29 @@ class PrinterManager:
 
             future.add_done_callback(handle_exception)
 
+    def last_known_trays(self, printer_id: int) -> dict:
+        """What this printer last had loaded, for a printer with no live client.
+
+        Only the tray keys, and only as history: a caller that wants to know
+        what a printer is reporting *now* must use :meth:`get_status`. This
+        exists because dropping a client drops its status with it, and the
+        queue reads the loaded filament to decide which offline printer is
+        worth switching on (#2876) — ``_power_on_and_wait`` replaces the client
+        on every attempt, so without this each attempt erased the reading the
+        next one needs.
+        """
+        return self._last_trays.get(printer_id, {})
+
+    def _remember_trays(self, printer_id: int) -> None:
+        """Keep the tray reading of a client that is about to be dropped."""
+        client = self._clients.get(printer_id)
+        if not client:
+            return
+        raw = client.state.raw_data or {}
+        remembered = {key: raw[key] for key in ("ams", "vt_tray") if raw.get(key)}
+        if remembered:
+            self._last_trays[printer_id] = remembered
+
     async def connect_printer(self, printer: Printer) -> bool:
         """Connect to a printer."""
         if printer.id in self._clients:
@@ -810,6 +840,7 @@ class PrinterManager:
     def disconnect_printer(self, printer_id: int, timeout: float = 0):
         """Disconnect from a printer."""
         if printer_id in self._clients:
+            self._remember_trays(printer_id)
             self._clients[printer_id].disconnect(timeout=timeout)
             del self._clients[printer_id]
         self._models.pop(printer_id, None)  # Clean up model cache

+ 140 - 0
backend/tests/unit/test_printer_manager_tray_carryover_2876.py

@@ -0,0 +1,140 @@
+"""Dropping a printer's client must not forget what filament it had (#2876).
+
+The queue's smart-plug step reads a printer's last known trays to decide
+which machine is worth switching on for a job. It gets there through
+``_power_on_and_wait``, which calls ``connect_printer`` in a retry loop --
+and that replaces the client object, taking its status with it. Before
+this, every attempt to wake a printer that did not come back erased the
+reading the next attempt depends on, so a farm that had been power-cycled
+a few times knew nothing about itself and went back to switching machines
+on one at a time.
+
+The record is kept beside the clients rather than inside one: it answers
+"what did this printer last have loaded", which is not the same question
+as "what is it reporting now", and nothing that displays or merges live
+status may confuse the two.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.printer_manager import PrinterManager
+
+AMS = [{"id": 0, "tray": [{"tray_type": "PLA", "tray_color": "161616FF"}]}]
+VT_TRAY = [{"id": 254, "tray_type": "ASA", "tray_color": "F98C36FF"}]
+
+
+@pytest.fixture
+def manager():
+    return PrinterManager()
+
+
+def _printer():
+    return SimpleNamespace(
+        id=1,
+        name="X1C-1",
+        ip_address="10.0.0.1",
+        access_code="x",
+        serial_number="X1C0001",
+        model="X1C",
+    )
+
+
+def _seed(manager, raw_data):
+    """Put a client holding ``raw_data`` where the manager will replace it."""
+    client = MagicMock()
+    client.state = PrinterState()
+    client.state.raw_data = raw_data
+    manager._clients[1] = client
+    return client
+
+
+async def _connect(manager):
+    """Run ``connect_printer`` against a fake MQTT client, return that client."""
+    created = MagicMock()
+    created.state = PrinterState()
+    with patch("backend.app.services.printer_manager.BambuMQTTClient", return_value=created):
+        await manager.connect_printer(_printer())
+    return created
+
+
+@pytest.mark.asyncio
+async def test_the_loaded_filament_survives_a_reconnect(manager):
+    _seed(manager, {"ams": AMS, "vt_tray": VT_TRAY})
+
+    await _connect(manager)
+
+    assert manager.last_known_trays(1) == {"ams": AMS, "vt_tray": VT_TRAY}
+
+
+def test_the_loaded_filament_survives_a_plain_disconnect(manager):
+    _seed(manager, {"ams": AMS})
+
+    manager.disconnect_printer(1)
+
+    assert manager.last_known_trays(1) == {"ams": AMS}
+
+
+@pytest.mark.asyncio
+async def test_the_new_client_starts_empty(manager):
+    """The record is history, not status.
+
+    Writing it into the reconnecting client's ``raw_data`` would put it in
+    front of everything that reads live status -- and the AMS merge is
+    additive, so an AMS unit unplugged while the printer was off would be
+    merged straight back in and never leave again.
+    """
+    _seed(manager, {"ams": AMS, "vt_tray": VT_TRAY})
+
+    created = await _connect(manager)
+
+    assert created.state.raw_data == {}
+
+
+@pytest.mark.asyncio
+async def test_nothing_but_the_trays_is_kept(manager):
+    """A stale temperature or print state read as current would be worse
+    than nothing; what is physically loaded survives a power cycle."""
+    _seed(manager, {"ams": AMS, "bed_temper": 60.0, "gcode_state": "RUNNING", "layer_num": 42})
+
+    await _connect(manager)
+
+    assert manager.last_known_trays(1) == {"ams": AMS}
+
+
+@pytest.mark.asyncio
+async def test_a_first_connection_has_nothing_to_remember(manager):
+    await _connect(manager)
+
+    assert manager.last_known_trays(1) == {}
+
+
+@pytest.mark.asyncio
+async def test_an_empty_reading_is_not_remembered_as_a_reading(manager):
+    """A printer that reported no trays leaves no trays behind.
+
+    The queue reads an absent record as "we have never heard" and switches
+    the printer on anyway; storing empty lists would say the same thing in
+    a shape that is harder to tell from a real answer.
+    """
+    _seed(manager, {"ams": [], "vt_tray": []})
+
+    await _connect(manager)
+
+    assert manager.last_known_trays(1) == {}
+
+
+@pytest.mark.asyncio
+async def test_a_later_reading_replaces_the_remembered_one(manager):
+    _seed(manager, {"vt_tray": VT_TRAY})
+    await _connect(manager)
+
+    _seed(manager, {"vt_tray": [{"id": 254, "tray_type": "PETG", "tray_color": "00FF00FF"}]})
+    await _connect(manager)
+
+    assert manager.last_known_trays(1)["vt_tray"][0]["tray_type"] == "PETG"

+ 323 - 2
backend/tests/unit/test_scheduler_class_target_smart_plug_2786.py

@@ -12,6 +12,7 @@ Auto On setting, powered a printer on the moment they edited it onto a specific
 printer -- and did nothing for the thirteen minutes before that.
 """
 
+import json
 from contextlib import ExitStack
 from datetime import datetime, timedelta, timezone
 from types import SimpleNamespace
@@ -84,8 +85,47 @@ async def _add_plug(ctx, printer_id, *, auto_on=True, enabled=True, name=None):
         return plug.id
 
 
+async def _add_printer(ctx, printer_id, *, model="X1C"):
+    """One more machine, for the cases that need a farm rather than a pair."""
+    async with ctx.session_maker() as db:
+        db.add(
+            Printer(
+                id=printer_id,
+                name=f"{model}-{printer_id}",
+                serial_number=f"{model}{printer_id:04d}",
+                ip_address=f"10.0.0.{printer_id}",
+                access_code="x",
+                model=model,
+                is_active=True,
+            )
+        )
+        await db.commit()
+
+
+def _tray(tray_type, color, *, idx=""):
+    return {"tray_type": tray_type, "tray_color": color, "tray_info_idx": idx}
+
+
+def _external(tray_type, color, *, idx=""):
+    """A printer whose filament sits on the external spool holder."""
+    return {"vt_tray": [dict(_tray(tray_type, color, idx=idx), id=254)]}
+
+
+def _ams(*trays):
+    return {"ams": [{"id": 0, "tray": list(trays)}]}
+
+
 async def _add_item(
-    ctx, *, printer_id=None, target_model=None, sliced_for="X1C", position=1, scheduled_time=None, manual_start=False
+    ctx,
+    *,
+    printer_id=None,
+    target_model=None,
+    sliced_for="X1C",
+    position=1,
+    scheduled_time=None,
+    manual_start=False,
+    required_filament_types=None,
+    filament_overrides=None,
 ):
     async with ctx.session_maker() as db:
         lib = LibraryFile(
@@ -105,6 +145,10 @@ async def _add_item(
             library_file_id=lib.id,
             scheduled_time=scheduled_time,
             manual_start=manual_start,
+            required_filament_types=(
+                json.dumps(required_filament_types) if required_filament_types is not None else None
+            ),
+            filament_overrides=json.dumps(filament_overrides) if filament_overrides is not None else None,
         )
         db.add(item)
         await db.commit()
@@ -120,6 +164,8 @@ async def _run(
     awaiting_plate_clear=(),
     require_plate_clear=True,
     launched=None,
+    statuses=None,
+    remembered=None,
 ):
     """Run one queue pass with every printer offline unless told otherwise.
 
@@ -139,7 +185,18 @@ async def _run(
                 "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
                 MagicMock(side_effect=lambda pid: pid in awaiting_plate_clear),
             ),
-            patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+            patch(
+                "backend.app.services.print_scheduler.printer_manager.get_status",
+                MagicMock(
+                    side_effect=lambda pid: (
+                        SimpleNamespace(raw_data=statuses[pid]) if statuses and pid in statuses else None
+                    )
+                ),
+            ),
+            patch(
+                "backend.app.services.print_scheduler.printer_manager.last_known_trays",
+                MagicMock(side_effect=lambda pid: (remembered or {}).get(pid, {})),
+            ),
             patch(
                 "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
                 AsyncMock(return_value={}),
@@ -422,3 +479,267 @@ class TestFixedPrinterBranchStillWakes:
         power_on = await _run(queue_db, PrintScheduler(), power_on=AsyncMock(return_value=True))
 
         assert _woken_printer_ids(power_on) == [2]
+
+
+class TestFilamentIsCheckedBeforeSwitchingOn:
+    """#2876: the colours are readable while the printers are off.
+
+    The wake step used to know only a printer's model, so a job for a colour
+    loaded on the far end of the farm switched machines on in ID order,
+    evaluated each once it booted, rejected it on colour and left it running.
+    Bambuddy held those colours the whole time -- a printer keeps its last
+    reported trays after the power goes.
+    """
+
+    @pytest.mark.asyncio
+    async def test_only_the_printer_that_can_take_the_job_is_woken(self, queue_db):
+        """The reporter's farm, minus the machines that were busy anyway."""
+        for pid in (3, 4):
+            await _add_printer(queue_db, pid)
+        for pid in (1, 2, 3, 4):
+            await _add_plug(queue_db, pid)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[{"type": "ASA", "color": "161616FF", "force_color_match": True}],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={
+                1: _external("ASA", "4B5320FF"),  # olive
+                2: _external("ASA", "898989FF"),  # grey
+                3: _external("ASA", "FFFFFFFF"),  # white
+                4: _external("ASA", "161616FF"),  # black -- the only one that can print it
+            },
+        )
+
+        assert _woken_printer_ids(power_on) == [4]
+
+    @pytest.mark.asyncio
+    async def test_a_printer_we_have_never_heard_from_is_still_woken(self, queue_db):
+        """No reading is not the same as no filament, and must not exclude.
+
+        Bambuddy holds the trays in memory only. A restart while the farm was
+        switched off leaves it knowing nothing, and concluding from that that
+        no printer can take the job would strand every queue on the planet.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={1: _external("PLA", "FFFFFFFF")},  # printer 2: nothing known
+        )
+
+        assert _woken_printer_ids(power_on) == [2]
+
+    @pytest.mark.asyncio
+    async def test_an_empty_reading_counts_as_unknown(self, queue_db):
+        """A powered-down AMS printer reports empty, which tells us nothing."""
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={1: {"ams": [], "vt_tray": []}, 2: _external("PLA", "FFFFFFFF")},
+        )
+
+        assert _woken_printer_ids(power_on) == [1]
+
+    @pytest.mark.asyncio
+    async def test_ams_trays_are_read_the_same_as_the_external_spool(self, queue_db):
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={
+                1: _ams(_tray("PLA", "FFFFFFFF"), _tray("PLA", "F98C36FF")),
+                2: _ams(_tray("PETG", "00FF00FF"), _tray("PLA", "161616FF")),
+            },
+        )
+
+        assert _woken_printer_ids(power_on) == [2]
+
+    @pytest.mark.asyncio
+    async def test_a_missing_filament_type_rules_a_printer_out(self, queue_db):
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(queue_db, target_model="X1C", required_filament_types=["PETG"])
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={1: _external("PLA", "FFFFFFFF"), 2: _external("PETG", "FFFFFFFF")},
+        )
+
+        assert _woken_printer_ids(power_on) == [2]
+
+    @pytest.mark.asyncio
+    async def test_a_preferred_colour_nobody_has_still_wakes_the_first_printer(self, queue_db):
+        """Preference overrides only order the field -- unless nothing matches.
+
+        The matcher skips a printer that has none of the preferred colours, so
+        the wake step passes over one too. With no printer holding any of them
+        the item is simply waiting for a filament change, and switching the
+        farm on will not produce one.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[{"type": "PLA", "color": "161616FF"}],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "F98C36FF")},
+        )
+
+        power_on.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_job_with_no_filament_requirement_wakes_as_before(self, queue_db):
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(queue_db, target_model="X1C")
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "161616FF")},
+        )
+
+        assert _woken_printer_ids(power_on) == [1]
+
+    @pytest.mark.asyncio
+    async def test_the_filament_variant_is_honoured(self, queue_db):
+        """Bambu reports every PLA variant as PLA; only tray_info_idx separates them (#2650)."""
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[
+                {"type": "PLA", "color": "FFFFFFFF", "tray_info_idx": "GFA01", "force_color_match": True}
+            ],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={
+                1: _external("PLA", "FFFFFFFF", idx="GFA00"),  # Basic, not Matte
+                2: _external("PLA", "FFFFFFFF", idx="GFA01"),
+            },
+        )
+
+        assert _woken_printer_ids(power_on) == [2]
+
+    @pytest.mark.asyncio
+    async def test_the_waiting_reason_says_filament_not_offline(self, queue_db):
+        """Not switching a printer on must not look like nothing happening.
+
+        Before, a wrong-colour printer was woken and then reported as needing
+        filament. Passing it over instead has to say the same thing, or the
+        job sits on "Offline:" while Bambuddy silently declines to act on it.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        item_id = await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[{"type": "PLA", "color": "161616FF", "color_name": "Black", "force_color_match": True}],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "F98C36FF")},
+        )
+
+        power_on.assert_not_awaited()
+        item = await _get_item(queue_db, item_id)
+        assert item.waiting_reason == "No matching material/color. Waiting on PLA (Black)"
+
+    @pytest.mark.asyncio
+    async def test_a_reading_kept_after_the_client_was_dropped_still_counts(self, queue_db):
+        """Waking a printer replaces its client, which drops its status.
+
+        The manager keeps the trays separately for exactly this reason -- a
+        power-on that times out must not leave the next queue check knowing
+        less than this one did.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            remembered={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "161616FF")},
+        )
+
+        assert _woken_printer_ids(power_on) == [2]
+
+    @pytest.mark.asyncio
+    async def test_what_the_printer_reported_beats_what_was_remembered(self, queue_db):
+        """The kept reading is a fallback, never an override.
+
+        Printer 1 is back and reporting black; the record from before it was
+        power-cycled says white. Acting on the record would pass over the one
+        printer that can take the job.
+        """
+        await _add_plug(queue_db, 1)
+        await _add_plug(queue_db, 2)
+        await _add_item(
+            queue_db,
+            target_model="X1C",
+            filament_overrides=[{"type": "PLA", "color": "161616FF", "force_color_match": True}],
+        )
+
+        power_on = await _run(
+            queue_db,
+            PrintScheduler(),
+            power_on=AsyncMock(return_value=True),
+            statuses={1: _external("PLA", "161616FF"), 2: _external("PLA", "FFFFFFFF")},
+            remembered={1: _external("PLA", "FFFFFFFF"), 2: _external("PLA", "161616FF")},
+        )
+
+        assert _woken_printer_ids(power_on) == [1]