Give an Any-model job to a printer that has enough filament (#3137)

This commit is contained in:
maziggy
2026-10-06 08:27:38 +02:00
parent 3e646febd6
commit e7f4ce0429
5 changed files with 783 additions and 15 deletions
+1
View File
@@ -138,6 +138,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
### Fixed
- **An "Any model" job was held for too little filament on one printer while another idle printer of that model had plenty (#3137, reported by @RambachTJ)** — The queue gave a job queued for **Any H2D Pro** (or any model) to the first idle printer with the right filament type, and only then checked whether its spools held enough. When they did not, the job waited on that printer for a manual start, even though another idle printer of the same model could run it; the only way out was to move it by hand. The queue now passes over a printer that would run short and gives the job to one that has enough. If every idle printer would run short, the job still waits on the first, where **Print Anyway** works as before, and moves by itself once another idle printer of its model has enough filament, on record in Inventory or Spoolman.
- **Shutting Bambuddy down logged an error with a traceback when nothing was wrong (#3243, reported by @Thomansky)** — Stopping Bambuddy cancels the printer updates still waiting to be handled, and each cancelled one was logged as `ERROR ... Exception in scheduled callback` with a traceback, as the last line before the restart. In a log or a support bundle it looked like a crash. A cancelled update now ends quietly; one that actually fails is still logged as an error.
- **H2D, H2D Pro and H2C with several AMS on one extruder marked the wrong tray as loaded on every filament change (#3242, reported by @Thomansky)** — These printers report the loading tray as a slot number (0-3) without saying which AMS it is in, and the report that says which AMS is read a moment later. In that gap Bambuddy assumed AMS 0: during a print feeding from AMS 2 slot 1, AMS 0 slot 1 showed as loaded for about a second, and that tray went into the list of tray changes that splits a print's filament between spools. Support packages show this on every filament change.
- **Now:** while a print runs, the slot is matched against the trays the print uses, taken from the printer's own mapping. If that doesn't settle it, as in a multi-colour print with the same slot in two AMS, the tray already loaded stays marked until the printer says which one is feeding.
+49 -8
View File
@@ -565,9 +565,25 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
async def compute_deficit_for_queue_item(
db: AsyncSession,
item: PrintQueueItem,
*,
printer_id: int | None = None,
ams_mapping: list[int] | None = None,
require_known: bool = False,
) -> list[FilamentDeficit]:
"""Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
``printer_id`` and ``ams_mapping`` ask about a printer the item is not
assigned to yet, with the mapping it would get there: the model-based
matcher uses them to pass over a printer that would be short (#3137)
before it commits the item to one. Without them, the item's own columns
are read.
``require_known`` also reports every printed slot whose remaining amount
cannot be determined (no tray mapped, no spool assigned, no label weight,
Spoolman not answering), with ``remaining_grams=None``. Everywhere else an
unknown amount lets the print through; a caller about to *start* a held
job on its own uses this so it only does so on a positive finding.
Returns an empty list whenever any of the following hold:
* The ``disable_filament_warnings`` setting is on.
@@ -591,7 +607,9 @@ async def compute_deficit_for_queue_item(
"""
if await _warnings_disabled(db):
return []
if item.printer_id is None:
if printer_id is None:
printer_id = item.printer_id
if printer_id is None:
return []
# Refresh the relationships we need without assuming the caller eagerly
@@ -627,12 +645,12 @@ async def compute_deficit_for_queue_item(
if not requirements:
return []
mapping = _parse_ams_mapping(item.ams_mapping)
mapping = ams_mapping if ams_mapping is not None else _parse_ams_mapping(item.ams_mapping)
if not mapping:
return []
spoolman_mode = await _is_spoolman_mode(db)
backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(item.printer_id)
backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
# ------------------------------------------------------------------ phase 1
# Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
@@ -652,6 +670,21 @@ async def compute_deficit_for_queue_item(
extruder: int
resolved: list[_ReqRow] = []
# Printed slots whose remaining amount is unknown; only returned with
# ``require_known``.
undetermined: list[FilamentDeficit] = []
def _undetermined(req: dict, ams_id: int | None = None, tray_id: int | None = None) -> None:
undetermined.append(
FilamentDeficit(
slot_id=req["slot_id"],
ams_id=ams_id,
tray_id=tray_id,
filament_type=str(req.get("type", "")),
required_grams=float(req["used_grams"]),
remaining_grams=None,
)
)
for req in requirements:
slot_id = req.get("slot_id")
@@ -662,9 +695,11 @@ async def compute_deficit_for_queue_item(
continue
idx = slot_id - 1
if idx >= len(mapping):
_undetermined(req)
continue
global_tray_id = mapping[idx]
if global_tray_id is None or global_tray_id < 0:
_undetermined(req)
continue
ams_id, tray_id = _global_to_ams_key(global_tray_id)
@@ -673,13 +708,14 @@ async def compute_deficit_for_queue_item(
if spoolman_mode:
sm_result = await db.execute(
select(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.printer_id == item.printer_id,
SpoolmanSlotAssignment.printer_id == printer_id,
SpoolmanSlotAssignment.ams_id == ams_id,
SpoolmanSlotAssignment.tray_id == tray_id,
)
)
sm_assignment = sm_result.scalar_one_or_none()
if sm_assignment is None:
_undetermined(req, ams_id, tray_id)
continue
# Live remaining_weight from Spoolman. The fetch also resolves the
# filament identity for pooling (material + colour + name).
@@ -712,25 +748,28 @@ async def compute_deficit_for_queue_item(
select(SpoolAssignment)
.options(selectinload(SpoolAssignment.spool))
.where(
SpoolAssignment.printer_id == item.printer_id,
SpoolAssignment.printer_id == printer_id,
SpoolAssignment.ams_id == ams_id,
SpoolAssignment.tray_id == tray_id,
)
)
assignment = internal_result.scalar_one_or_none()
if assignment is None or assignment.spool is None:
_undetermined(req, ams_id, tray_id)
continue
spool = assignment.spool
identity = _material_identity_internal(spool)
label_weight = float(spool.label_weight or 0)
weight_used = float(spool.weight_used or 0)
if label_weight <= 0:
_undetermined(req, ams_id, tray_id)
continue
remaining = max(0.0, label_weight - weight_used)
if remaining is None:
# Unable to determine remaining grams — preserve pre-#1762 behaviour
# (don't block on undetermined data).
_undetermined(req, ams_id, tray_id)
continue
resolved.append(
@@ -751,8 +790,10 @@ async def compute_deficit_for_queue_item(
# When backup is OFF, fall back to today's per-slot accounting (one-line
# equivalence of the original loop), so this path is a strict no-op
# behaviour-wise vs. the pre-#1762 code.
unknown = undetermined if require_known else []
if not backup_on:
return [
return unknown + [
FilamentDeficit(
slot_id=row.slot_id,
ams_id=row.ams_id,
@@ -773,7 +814,7 @@ async def compute_deficit_for_queue_item(
pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
required_by_key: dict[tuple[str, int], float] = defaultdict(float)
for slot in await build_slot_materials(db, item.printer_id):
for slot in await build_slot_materials(db, printer_id):
pool_by_key[(slot.material_key, slot.extruder)] += slot.remaining_grams
for row in resolved:
@@ -797,7 +838,7 @@ async def compute_deficit_for_queue_item(
)
)
return deficits
return unknown + deficits
# Re-export the most useful pieces for callers that just want the data.
+214 -7
View File
@@ -7,7 +7,7 @@ import time
import uuid
import zipfile
from collections import deque
from collections.abc import Mapping
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -604,6 +604,11 @@ def _is_tray_id(value: object) -> bool:
# a staged item (#3074); this one is the reason it was staged, so it stays.
_UNMATCHED_HOLD_PREFIX = "Needs "
# How often an "any model" job held for low filament looks for another printer
# of its model that has enough (#3137). Each look matches its filament on every
# idle printer again, which a 30 s queue pass would otherwise repeat each time.
_SHORT_HOLD_RECHECK_SECONDS = 120
def _is_unmatched_hold_reason(reason: str | None) -> bool:
"""True for the reason the unmatched-filament hold wrote when it staged the item."""
@@ -1277,6 +1282,9 @@ class PrintScheduler:
# pass. Cleared at the top of each pass so a re-sliced file is never
# served from a previous tick.
self._filament_req_memo: dict[tuple, list[dict] | None] = {}
# When each "any model" job held for low filament last looked for
# another printer (#3137), by queue item id.
self._short_hold_checked_at: dict[int, float] = {}
async def run(self):
"""Main loop - check queue every interval."""
@@ -1543,6 +1551,11 @@ class PrintScheduler:
len(items),
[(i.id, i.printer_id, i.archive_id, i.library_file_id) for i in items],
)
# Forget jobs that left the queue since they were last checked.
pending_ids = {i.id for i in items}
self._short_hold_checked_at = {
item_id: at for item_id, at in self._short_hold_checked_at.items() if item_id in pending_ids
}
# Seed busy_printers with printers that already have an item in 'printing'
# status. _is_printer_idle() alone is not sufficient as a dispatch gate —
@@ -1852,8 +1865,19 @@ class PrintScheduler:
skip_reasons["scheduled_future"] = skip_reasons.get("scheduled_future", 0) + 1
continue
# Skip items that require manual start
if item.manual_start:
# Skip items that require manual start, except an "any model"
# job held for low filament that another idle printer can now
# run: that one is moved there and carries on below (#3137).
if item.manual_start and not (
item.filament_short
and await self._release_short_model_hold(
db,
item,
busy_printers | interlocked.keys(),
require_plate_clear,
await _creator_scope(item.created_by_id),
)
):
# Waiting on the user, not on a printer. Cleared here because
# this is the last pass that will look at the row: a staged
# item never reaches the branches below again, so a reason
@@ -1867,6 +1891,13 @@ class PrintScheduler:
skip_reasons["manual_start"] = skip_reasons.get("manual_start", 0) + 1
continue
if item.printer_id and item.printer_id not in pinned_printers:
# Moved by the release above to a printer no row named when
# the labels were read.
moved_to = await self._get_printer(db, item.printer_id)
if moved_to is not None:
pinned_printers[item.printer_id] = (moved_to.name or "", moved_to.model or "")
if item.printer_id:
# Its creator may no longer use this printer (#1727): an
# admin took it away from their group after the job was
@@ -2166,6 +2197,17 @@ class PrintScheduler:
continue
wakeable_candidates.append(candidate)
async def is_short(
pid: int, item: PrintQueueItem = item, candidate: _ModelCandidate = candidate
) -> bool:
# The check reads the file and plate off the item,
# so it must hold this candidate's. Resolving is
# safe to repeat, and the winner is resolved again
# below.
self._resolve_variant(item, candidate)
return await self._filament_short_on(db, item, pid)
match_id, match_reason = await self._find_idle_printer_for_model(
db,
candidate.target_model,
@@ -2179,6 +2221,7 @@ class PrintScheduler:
require_plate_clear=require_plate_clear,
wakeable_ids=wakeable_printer_ids,
printer_scope=item_scope,
is_short=is_short,
)
if match_id:
printer_id = match_id
@@ -3005,6 +3048,7 @@ class PrintScheduler:
require_plate_clear: bool = True,
wakeable_ids: set[int] | None = None,
printer_scope: PrinterScope = ALL_PRINTERS,
is_short: Callable[[int], Awaitable[bool]] | None = None,
) -> tuple[int | None, str | None]:
"""Find an idle, connected printer matching the model with compatible filaments.
@@ -3022,6 +3066,11 @@ class PrintScheduler:
wakeable_ids: Printers a smart plug can power on (#2786). Only changes how an
offline printer is worded: one Bambuddy will switch on reads
differently from one the user has to go and switch on themselves.
is_short: Says whether a printer's spools are too light for the job (#3137).
A printer it flags is passed over while another eligible one
is not. When every eligible printer is short, the first is
returned all the same, so the deficit gate holds the job there
and "Print Anyway" still has a printer to print on.
Returns:
Tuple of (printer_id, waiting_reason):
@@ -3045,6 +3094,9 @@ class PrintScheduler:
printers_offline_no_plug = []
printers_missing_filament: list[tuple[str, list[str]]] = []
candidates: list[tuple[int, int]] = [] # (printer_id, color_match_count)
# The first eligible printer that is short on filament, kept in case
# every eligible printer is (#3137).
short_fallback: int | None = None
for printer in printers:
if printer.id in exclude_ids:
@@ -3141,18 +3193,36 @@ class PrintScheduler:
printers_missing_filament.append((printer.name, override_colors))
logger.debug("Skipping printer %s (%s) - no matching override colors", printer.id, printer.name)
continue
elif force_overrides:
# Passed all force checks — immediately eligible (no preference ordering needed)
return printer.id, None
else:
# No overrides at all - take first available (existing behavior)
# No preference ordering needed: the first eligible printer
# wins, unless its spools are too light for the job (#3137).
if is_short is not None and await is_short(printer.id):
if short_fallback is None:
short_fallback = printer.id
logger.info(
"Model-based assignment: passing over printer %s (%s), not enough filament",
printer.id,
printer.name,
)
continue
return printer.id, None
# If we have candidates from preference override matching, pick the one with most color matches
if candidates:
candidates.sort(key=lambda c: c[1], reverse=True)
if is_short is not None:
for candidate_id, _ in candidates:
if not await is_short(candidate_id):
return candidate_id, None
logger.info(
"Model-based assignment: passing over printer %s, not enough filament",
candidate_id,
)
return candidates[0][0], None
if short_fallback is not None:
return short_fallback, None
# Build waiting reason from what we found
reasons = []
if printers_missing_filament:
@@ -7630,6 +7700,143 @@ class PrintScheduler:
except Exception:
pass # toast is best-effort
async def _filament_short_on(
self,
db: AsyncSession,
item: PrintQueueItem,
printer_id: int,
*,
require_known: bool = False,
) -> bool:
"""Would ``item`` be held for a filament deficit on ``printer_id`` (#3137)?
Asks what ``_block_on_filament_deficit`` would answer once the item is
assigned there, with the mapping the item would get there, but changes
nothing. The model-based matcher uses it to prefer a printer whose
spools can finish the job.
False whenever the gate would let the item through: "Print Anyway",
no mapping, or a check that fails. With ``require_known`` all three of
those, and any printed slot whose remaining amount is unknown, count
as short instead: releasing a held job starts it with nobody asked, so
it needs the amount on record, not merely nothing against it.
"""
if item.skip_filament_check:
return False
try:
mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
if not mapping:
return require_known
deficit = await compute_deficit_for_queue_item(
db, item, printer_id=printer_id, ams_mapping=mapping, require_known=require_known
)
except Exception as e:
logger.warning("Filament deficit check failed for item %s on printer %s: %s", item.id, printer_id, e)
return require_known
return bool(deficit)
async def _release_short_model_hold(
self,
db: AsyncSession,
item: PrintQueueItem,
unavailable: set[int],
require_plate_clear: bool,
printer_scope: PrinterScope,
) -> bool:
"""Free an "any model" job held for low filament once a printer can run it (#3137).
The deficit gate holds a model-based job on the printer the matcher
gave it, because "Print Anyway" needs a printer to print on. When
another idle printer of the job's model has the filament, the job is
moved to that printer and its hold lifted, and the fixed-printer
branch dispatches it in this same pass, through the same checks a
held job meets when the user presses Start. It is moved rather than
put back in the pool because the model-based branch would pick again
without requiring the amounts on record (below), and could pick a
different printer. Only the deficit gate sets ``filament_short``, so a
job held for any other reason stays held.
The printer it is held on is left out: a spool loaded there is started
with Start, as before, rather than while someone may still be loading
the next one. Each job is checked at most every
``_SHORT_HOLD_RECHECK_SECONDS``, as every check matches its filament
on each idle printer again.
Cross-model jobs (#671) stay held: checking a variant writes its file
onto the item, which would leave a job that is not moved holding
another variant's file for the printer it waits on.
A printer qualifies only on a positive finding: every slot the plate
prints mapped to a spool whose remaining amount is on record and
enough. A printer whose spools Bambuddy does not track is not one.
Returns True when the job was moved. A check that fails keeps the hold
rather than stopping the queue pass.
"""
if not item.filament_short or item.skip_filament_check or item.printer_id is None:
return False
if not item.target_model or item.variants:
return False
now = time.monotonic()
if now - self._short_hold_checked_at.get(item.id, float("-inf")) < _SHORT_HOLD_RECHECK_SECONDS:
return False
self._short_hold_checked_at[item.id] = now
try:
candidates = _candidates_for(item)
if not candidates:
return False
effective_types, filament_overrides = _filament_constraints(candidates[0])
async def is_short(pid: int) -> bool:
return await self._filament_short_on(db, item, pid, require_known=True)
match_id, _ = await self._find_idle_printer_for_model(
db,
item.target_model,
unavailable | {item.printer_id},
effective_types,
item.target_location,
filament_overrides=filament_overrides,
require_plate_clear=require_plate_clear,
printer_scope=printer_scope,
is_short=is_short,
)
# Every idle printer short comes back as the first of them.
if match_id is None or await is_short(match_id):
return False
except Exception as e:
logger.warning("Queue item %s: looking for another printer failed: %s", item.id, e)
return False
logger.info(
"Queue item %s: printer %s has the filament it was short of on printer %s — moving it there",
item.id,
match_id,
item.printer_id,
)
self._short_hold_checked_at.pop(item.id, None)
item.printer_id = match_id
# Resolved against the printer it leaves (#3239).
item.ams_mapping = None
item.manual_start = False
item.filament_short = False
item.waiting_reason = None
await db.commit()
try:
printer = await self._get_printer(db, match_id)
await notification_service.on_queue_job_assigned(
job_name=await self._get_job_name(db, item),
printer_id=match_id,
printer_name=printer.name if printer else "Unknown",
target_model=item.target_model,
db=db,
)
except Exception as e:
logger.debug("Assignment notification failed for item %s: %s", item.id, e)
return True
async def _block_on_filament_deficit(
self,
db: AsyncSession,
@@ -219,6 +219,95 @@ class TestFilamentDeficit:
assert deficit == []
@pytest.mark.asyncio
async def test_checks_a_printer_the_item_is_not_assigned_to(self, db_session, printer_factory, tmp_path):
"""The model-based matcher asks before assigning (#3137): the printer
and mapping it passes are read, not the item's empty columns."""
light = await printer_factory()
full = await printer_factory()
archive = await _setup_archive_3mf(
db_session,
tmp_path,
[{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
)
short_spool = await _spool(db_session, label_weight=1000, weight_used=970.0) # 30g left
await _assign(db_session, printer_id=light.id, spool_id=short_spool.id, ams_id=0, tray_id=1)
full_spool = await _spool(db_session, label_weight=1000, weight_used=200.0) # 800g left
await _assign(db_session, printer_id=full.id, spool_id=full_spool.id, ams_id=0, tray_id=1)
item = await _queue_item(db_session, printer_id=None, archive=archive, ams_mapping=None)
with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
on_light = await compute_deficit_for_queue_item(db_session, item, printer_id=light.id, ams_mapping=[1])
on_full = await compute_deficit_for_queue_item(db_session, item, printer_id=full.id, ams_mapping=[1])
assert [d.remaining_grams for d in on_light] == [30.0]
assert on_full == []
@pytest.mark.asyncio
async def test_passed_mapping_wins_over_the_stored_one(self, db_session, printer_factory, tmp_path):
printer = await printer_factory()
archive = await _setup_archive_3mf(
db_session,
tmp_path,
[{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
)
short_spool = await _spool(db_session, label_weight=1000, weight_used=970.0)
await _assign(db_session, printer_id=printer.id, spool_id=short_spool.id, ams_id=0, tray_id=0)
full_spool = await _spool(db_session, label_weight=1000, weight_used=200.0)
await _assign(db_session, printer_id=printer.id, spool_id=full_spool.id, ams_id=0, tray_id=1)
item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
stored = await compute_deficit_for_queue_item(db_session, item)
passed = await compute_deficit_for_queue_item(db_session, item, ams_mapping=[1])
assert len(stored) == 1
assert passed == []
@pytest.mark.asyncio
async def test_require_known_reports_slots_it_cannot_measure(self, db_session, printer_factory, tmp_path):
"""Unknown amounts let a print through, except where the caller asks for
them on record (#3137): a slot with no spool, and a slot with no tray."""
printer = await printer_factory()
archive = await _setup_archive_3mf(
db_session,
tmp_path,
[
{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"},
{"id": "2", "type": "PLA", "color": "#000000", "used_g": "50.0"},
{"id": "3", "type": "PLA", "color": "#FF0000", "used_g": "20.0"},
],
)
spool = await _spool(db_session, label_weight=1000, weight_used=200.0)
await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=None)
with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
lenient = await compute_deficit_for_queue_item(db_session, item, ams_mapping=[0, 1, -1])
strict = await compute_deficit_for_queue_item(db_session, item, ams_mapping=[0, 1, -1], require_known=True)
assert lenient == []
assert sorted((d.slot_id, d.remaining_grams) for d in strict) == [(2, None), (3, None)]
@pytest.mark.asyncio
async def test_require_known_passes_a_printer_with_everything_on_record(
self, db_session, printer_factory, tmp_path
):
printer = await printer_factory()
archive = await _setup_archive_3mf(
db_session,
tmp_path,
[{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
)
spool = await _spool(db_session, label_weight=1000, weight_used=200.0)
await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
strict = await compute_deficit_for_queue_item(db_session, item, require_known=True)
assert strict == []
@pytest.mark.asyncio
async def test_returns_empty_when_warnings_disabled(self, db_session, printer_factory, tmp_path):
"""Honour the disable_filament_warnings setting (#720 toggle)."""
@@ -0,0 +1,430 @@
"""An "any model" job goes to a printer that has the filament for it (#3137).
The reporter's farm queues jobs for "any H2D Pro". The matcher took the first
idle H2D Pro with the right filament *type*, and only then did the deficit gate
look at the amount. It held the job on that printer, pinned there, while
another idle H2D Pro had plenty. The job only ran once the user moved it there
by hand.
Now the matcher passes over a printer that would be short while another
eligible one is not. When every idle printer is short the job is held on the
first, as before, so "Print Anyway" still has a printer to print on; and once
another printer of its model can run it, the hold is released and the job
goes there.
"""
from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
from backend.app.core.database import Base
from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.filament_deficit import FilamentDeficit
from backend.app.services.print_scheduler import PrintScheduler
_DEFICIT = [
FilamentDeficit(
slot_id=1,
ams_id=0,
tray_id=0,
filament_type="PLA",
required_grams=120.0,
remaining_grams=40.0,
)
]
@pytest.fixture
async def queue_db():
"""Three X1Cs, in id order, so "the first idle printer" is well defined."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
async with session_maker() as db:
db.add_all(
[
Printer(
id=pid,
name=f"X1C-{pid}",
serial_number=f"X1C000{pid}",
ip_address=f"10.0.0.{pid}",
access_code="x",
model="X1C",
is_active=True,
)
for pid in (1, 2, 3)
]
)
await db.commit()
try:
yield SimpleNamespace(session_maker=session_maker)
finally:
await engine.dispose()
async def _add_item(ctx, **fields):
async with ctx.session_maker() as db:
lib = LibraryFile(
filename="job.gcode.3mf",
file_path="/library/job.gcode.3mf",
file_size=10,
file_type="gcode.3mf",
file_metadata={"sliced_for_model": "X1C"},
)
db.add(lib)
await db.flush()
values = {"status": "pending", "position": 1, "target_model": "X1C", "library_file_id": lib.id}
values.update(fields)
item = PrintQueueItem(**values)
db.add(item)
await db.commit()
return item.id
async def _get_item(ctx, item_id):
async with ctx.session_maker() as db:
return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
def _short_on(short: set[int], unknown: frozenset[int] = frozenset()) -> AsyncMock:
"""``_filament_short_on`` stand-in: the printers in ``short`` are too light,
and those in ``unknown`` have spools whose amount is not on record."""
async def check(db, item, printer_id, *, require_known=False):
return printer_id in short or (require_known and printer_id in unknown)
return AsyncMock(side_effect=check)
async def _run(
ctx, scheduler, *, short: set[int], launched, idle=lambda pid: True, short_check=None, unknown=frozenset()
):
"""One queue pass. The deficit gate is real; only the deficit it reads is faked."""
async def deficit(db, item, *, printer_id=None, ams_mapping=None):
return _DEFICIT if (printer_id or item.printer_id) in short else []
patches = [
patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
patch("backend.app.core.database.async_session", ctx.session_maker),
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
patch(
"backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
AsyncMock(return_value={}),
),
patch(
"backend.app.services.notification_service.notification_service.on_queue_job_waiting",
AsyncMock(),
),
patch(
"backend.app.services.notification_service.notification_service.on_queue_job_assigned",
AsyncMock(),
),
patch("backend.app.services.print_scheduler.compute_deficit_for_queue_item", AsyncMock(side_effect=deficit)),
patch.object(scheduler, "_filament_short_on", short_check or _short_on(short, unknown)),
patch.object(scheduler, "_is_printer_idle", MagicMock(side_effect=lambda pid, *a, **k: idle(pid))),
patch.object(scheduler, "_check_auto_drying", AsyncMock()),
patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
patch.object(scheduler, "_block_on_unmatched_filament", AsyncMock(return_value=False)),
patch.object(scheduler, "_launch_uploads", launched),
]
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
return await scheduler.check_queue()
class TestMatcher:
"""``_find_idle_printer_for_model`` with and without the shortfall check."""
async def _find(self, ctx, scheduler, *, short=None, **kwargs):
async def is_short(pid):
return pid in short
with (
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
):
async with ctx.session_maker() as db:
return await scheduler._find_idle_printer_for_model(
db, "X1C", set(), is_short=is_short if short is not None else None, **kwargs
)
@pytest.mark.asyncio
async def test_passes_over_a_short_printer(self, queue_db):
assert await self._find(queue_db, PrintScheduler(), short={1}) == (2, None)
@pytest.mark.asyncio
async def test_every_printer_short_returns_the_first(self, queue_db):
"""The deficit gate then holds the job there, where Print Anyway can start it."""
assert await self._find(queue_db, PrintScheduler(), short={1, 2, 3}) == (1, None)
@pytest.mark.asyncio
async def test_without_the_check_the_first_idle_printer_wins(self, queue_db):
assert await self._find(queue_db, PrintScheduler()) == (1, None)
@pytest.mark.asyncio
async def test_force_colour_jobs_pass_over_a_short_printer(self, queue_db):
scheduler = PrintScheduler()
overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True}]
with patch.object(scheduler, "_get_missing_force_color_slots", MagicMock(return_value=[])):
result = await self._find(queue_db, scheduler, short={1}, filament_overrides=overrides)
assert result == (2, None)
@pytest.mark.asyncio
async def test_colour_ranking_takes_the_best_printer_that_is_not_short(self, queue_db):
scheduler = PrintScheduler()
overrides = [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}]
matches = {1: 1, 2: 2, 3: 1}
with patch.object(
scheduler, "_count_override_color_matches", MagicMock(side_effect=lambda pid, _o: matches[pid])
):
ranked_first = await self._find(queue_db, scheduler, short=set(), filament_overrides=overrides)
best_is_short = await self._find(queue_db, scheduler, short={2}, filament_overrides=overrides)
all_short = await self._find(queue_db, scheduler, short={1, 2, 3}, filament_overrides=overrides)
assert ranked_first == (2, None)
assert best_is_short == (1, None)
assert all_short == (2, None)
class TestAssignment:
@pytest.mark.asyncio
async def test_goes_to_the_printer_with_enough_filament(self, queue_db):
"""The reporter's case: printer 1 is idle but light, printer 2 idle and full."""
item_id = await _add_item(queue_db)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
item = await _get_item(queue_db, item_id)
assert item.printer_id == 2
assert item.manual_start is False
assert item.filament_short is False
@pytest.mark.asyncio
async def test_held_on_the_first_printer_when_every_idle_printer_is_short(self, queue_db):
item_id = await _add_item(queue_db)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), short={1, 2, 3}, launched=launched)
launched.assert_not_called()
item = await _get_item(queue_db, item_id)
assert item.printer_id == 1
assert item.manual_start is True
assert item.filament_short is True
@pytest.mark.asyncio
async def test_print_anyway_is_not_second_guessed(self, queue_db):
"""With Print Anyway set, no printer is passed over for being short."""
item_id = await _add_item(queue_db, skip_filament_check=True)
launched = MagicMock()
scheduler = PrintScheduler()
with patch(
"backend.app.services.print_scheduler.PrintScheduler._compute_ams_mapping_for_printer",
AsyncMock(return_value=[0]),
):
await _run(
queue_db,
scheduler,
short={1},
launched=launched,
short_check=AsyncMock(side_effect=scheduler._filament_short_on),
)
launched.assert_called_once()
assert (await _get_item(queue_db, item_id)).printer_id == 1
class TestHeldJob:
@pytest.mark.asyncio
async def test_moves_to_another_printer_that_has_the_filament(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
item = await _get_item(queue_db, item_id)
assert item.printer_id == 2
assert item.manual_start is False
assert item.filament_short is False
@pytest.mark.asyncio
async def test_stays_held_while_every_other_printer_is_short(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), short={1, 2, 3}, launched=launched)
launched.assert_not_called()
item = await _get_item(queue_db, item_id)
assert item.printer_id == 1
assert item.manual_start is True
assert item.filament_short is True
@pytest.mark.asyncio
async def test_stays_held_while_the_other_printers_are_busy(self, queue_db):
"""Item 2080 in the reporter's log: the printer it waits on was the only idle one."""
item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
launched = MagicMock()
short_check = _short_on({1})
await _run(
queue_db, PrintScheduler(), short={1}, launched=launched, idle=lambda pid: pid == 1, short_check=short_check
)
launched.assert_not_called()
assert (await _get_item(queue_db, item_id)).printer_id == 1
# The printer it is held on is not checked again: a spool loaded there
# is started with Start, as before.
short_check.assert_not_called()
@pytest.mark.asyncio
async def test_a_job_the_user_put_on_a_printer_is_not_moved(self, queue_db):
item_id = await _add_item(queue_db, target_model=None, printer_id=1, manual_start=True, filament_short=True)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), short={1}, launched=launched)
launched.assert_not_called()
item = await _get_item(queue_db, item_id)
assert item.printer_id == 1
assert item.manual_start is True
@pytest.mark.asyncio
async def test_a_job_held_for_another_reason_is_not_moved(self, queue_db):
"""Only the deficit gate sets filament_short; a plain manual start waits for the user."""
item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=False)
launched = MagicMock()
short_check = _short_on(set())
await _run(queue_db, PrintScheduler(), short=set(), launched=launched, short_check=short_check)
launched.assert_not_called()
assert (await _get_item(queue_db, item_id)).manual_start is True
short_check.assert_not_called()
@pytest.mark.asyncio
async def test_not_moved_to_a_printer_whose_spools_are_not_tracked(self, queue_db):
"""Moving starts the job with nobody asked, so "nothing against it" is not enough."""
item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), short={1}, unknown={2, 3}, launched=launched)
launched.assert_not_called()
item = await _get_item(queue_db, item_id)
assert item.printer_id == 1
assert item.manual_start is True
@pytest.mark.asyncio
async def test_goes_to_the_printer_that_was_checked(self, queue_db):
"""Printer 2 comes first and has nothing against it, but only printer 3
has the amount on record: the job goes to 3, not to whichever printer a
fresh pick would find first."""
item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), short={1}, unknown={2}, launched=launched)
launched.assert_called_once()
item = await _get_item(queue_db, item_id)
assert item.printer_id == 3
assert item.target_model == "X1C"
assert item.manual_start is False
assert item.filament_short is False
assert item.waiting_reason is None
@pytest.mark.asyncio
async def test_a_failing_check_keeps_the_hold_and_the_pass_going(self, queue_db):
held_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
other_id = await _add_item(queue_db, position=2, target_model=None, printer_id=3)
launched = MagicMock()
scheduler = PrintScheduler()
with patch.object(scheduler, "_find_idle_printer_for_model", AsyncMock(side_effect=RuntimeError("boom"))):
await _run(queue_db, scheduler, short={1}, launched=launched)
held = await _get_item(queue_db, held_id)
assert held.printer_id == 1
assert held.manual_start is True
launched.assert_called_once()
assert launched.call_args[0][0] == [other_id]
@pytest.mark.asyncio
async def test_looks_again_only_after_the_recheck_interval(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1, manual_start=True, filament_short=True)
scheduler = PrintScheduler()
short_check = _short_on({1, 2, 3})
await _run(queue_db, scheduler, short={1, 2, 3}, launched=MagicMock(), short_check=short_check)
first_pass_calls = short_check.await_count
await _run(queue_db, scheduler, short={1, 2, 3}, launched=MagicMock(), short_check=short_check)
assert short_check.await_count == first_pass_calls
scheduler._short_hold_checked_at[item_id] -= 121
launched = MagicMock()
await _run(queue_db, scheduler, short={1}, launched=launched)
launched.assert_called_once()
assert (await _get_item(queue_db, item_id)).printer_id == 2
class TestFilamentShortOn:
"""``_filament_short_on``'s answer where it cannot see the amounts."""
@staticmethod
async def _ask(*, mapping=None, deficit=None, raises=None, skip=False, require_known=False):
scheduler = PrintScheduler()
item = PrintQueueItem(id=7, skip_filament_check=skip)
compute = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=mapping)
with (
patch.object(scheduler, "_compute_ams_mapping_for_printer", compute),
patch(
"backend.app.services.print_scheduler.compute_deficit_for_queue_item",
AsyncMock(return_value=deficit or []),
),
):
return await scheduler._filament_short_on(MagicMock(), item, 1, require_known=require_known)
@pytest.mark.asyncio
async def test_a_deficit_is_short(self):
assert await self._ask(mapping=[0], deficit=_DEFICIT) is True
assert await self._ask(mapping=[0], deficit=_DEFICIT, require_known=True) is True
@pytest.mark.asyncio
async def test_enough_is_not_short(self):
assert await self._ask(mapping=[0]) is False
assert await self._ask(mapping=[0], require_known=True) is False
@pytest.mark.asyncio
async def test_no_mapping_counts_against_the_printer_only_when_required_known(self):
assert await self._ask(mapping=None) is False
assert await self._ask(mapping=None, require_known=True) is True
@pytest.mark.asyncio
async def test_a_failing_check_counts_against_the_printer_only_when_required_known(self):
assert await self._ask(raises=RuntimeError("boom")) is False
assert await self._ask(raises=RuntimeError("boom"), require_known=True) is True
@pytest.mark.asyncio
async def test_print_anyway_is_never_short(self):
assert await self._ask(mapping=[0], deficit=_DEFICIT, skip=True) is False
assert await self._ask(mapping=[0], deficit=_DEFICIT, skip=True, require_known=True) is False