diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 4a1107097..518a1884a 100644 --- a/backend/app/services/print_scheduler.py +++ b/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). diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index d164bc228..7e1c11fee 100644 --- a/backend/app/services/printer_manager.py +++ b/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 diff --git a/backend/tests/unit/test_printer_manager_tray_carryover_2876.py b/backend/tests/unit/test_printer_manager_tray_carryover_2876.py new file mode 100644 index 000000000..d3aeb42dd --- /dev/null +++ b/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" diff --git a/backend/tests/unit/test_scheduler_class_target_smart_plug_2786.py b/backend/tests/unit/test_scheduler_class_target_smart_plug_2786.py index 0d7884dc2..ce0794a4d 100644 --- a/backend/tests/unit/test_scheduler_class_target_smart_plug_2786.py +++ b/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]