mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user