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.
This commit is contained in:
maziggy
2026-08-19 09:32:49 +02:00
4 changed files with 685 additions and 51 deletions
+191 -49
View File
@@ -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
View File
@@ -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
@@ -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"
@@ -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]