Explorar o código

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 hai 2 semanas
pai
achega
a13e9f0d8e

+ 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]