Files

348 lines
12 KiB
Python

"""Eligibility matcher for Slicer Pipeline runs (#1425 PR B).
Given a pipeline + the user's pinned target printer, this returns a structured
report of issues the operator should resolve before running. The frontend
displays the report; the user can ``Run anyway`` to proceed (lenient policy —
the print may still fail at the printer, but Bambuddy isn't going to refuse
the click).
Issue kinds (pinned for tests + i18n keys):
- printer_not_set — pipeline has no target_printer_id
- printer_not_found — target_printer_id points at a deleted/missing row
- printer_disabled — Printer.is_active is False (#1476)
- printer_offline — MQTT not connected
- filament_type_mismatch — AMS slot loaded with wrong filament type
- filament_color_mismatch — type matches, colour differs
- ams_slot_missing — pipeline expects N filament slots but AMS exposes fewer
- filament_unverified — pipeline filament preset is a non-local tier we
can't statically read (cloud / orca_cloud / standard);
the run will proceed, but the operator should
double-check
The matcher is a pure-ish function over (pipeline, printer row, live AMS state,
local-preset dict) so unit tests can drive it with fixtures without spinning up
MQTT. The route handler is the only place that talks to ``printer_manager``.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Literal
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.local_preset import LocalPreset
from backend.app.models.printer import Printer
from backend.app.models.slicer_pipeline import SlicerPipeline
from backend.app.utils.filament_types import canonical_filament_type
IssueKind = Literal[
"printer_not_set",
"printer_not_found",
"printer_disabled",
"printer_offline",
"filament_type_mismatch",
"filament_color_mismatch",
"ams_slot_missing",
"filament_unverified",
"no_class_matches",
"class_not_set",
]
@dataclass(frozen=True)
class EligibilityIssue:
kind: IssueKind
slot_index: int | None = None
expected: str | None = None
actual: str | None = None
@dataclass(frozen=True)
class PerPrinterReport:
"""One row of the class-targeting eligibility breakdown."""
printer_id: int
printer_name: str
ok: bool
issues: tuple[EligibilityIssue, ...]
@dataclass(frozen=True)
class EligibilityReport:
ok: bool
target_kind: Literal["specific_printer", "printer_class"]
target_printer_id: int | None
target_printer_name: str | None
target_model_class: str | None
issues: tuple[EligibilityIssue, ...]
printer_reports: tuple[PerPrinterReport, ...] = ()
# This module's whole job is to predict what the dispatch matcher will do, so
# it reads type equivalence from the same table the matcher does rather than
# keeping a copy. The copy it used to keep had drifted into disagreeing in both
# directions — it aliased "PLA Basic" to "PLA" where the matcher does not, so a
# job could pass here and then fail on type; and it lacked the PA12-CF/PAHT-CF
# grouping the matcher has, so a job the matcher handles fine was flagged.
_canonical = canonical_filament_type
def _normalise_colour(colour: str | None) -> str:
if not colour:
return ""
return colour.replace("#", "").lower()[:6]
def _ams_slots(raw_data: dict) -> list[tuple[str, str]]:
"""Flatten AMS + external spool into ``[(type, colour_hex6), ...]`` in slot
order. Uses the same field shape as print_scheduler._check_required_filaments.
"""
out: list[tuple[str, str]] = []
for ams_unit in raw_data.get("ams") or []:
for tray in ams_unit.get("tray") or []:
tray_type = tray.get("tray_type") or ""
tray_colour = tray.get("tray_color") or ""
out.append((_canonical(tray_type), _normalise_colour(tray_colour)))
for vt in raw_data.get("vt_tray") or []:
vt_type = vt.get("tray_type") or ""
vt_colour = vt.get("tray_color") or ""
out.append((_canonical(vt_type), _normalise_colour(vt_colour)))
return out
async def _expected_filament(
db: AsyncSession,
source: str,
preset_id: str,
) -> tuple[str | None, str | None]:
"""Return ``(canonical_type, normalised_colour)`` for a pipeline filament
slot's PresetRef, or ``(None, None)`` when the preset can't be resolved
statically (cloud / orca_cloud / standard — read at slice time, not here).
"""
if source != "local":
# Cloud / orca_cloud / standard: surface as ``filament_unverified``
# in the report, the matcher decides.
return (None, None)
try:
local_id = int(preset_id)
except (TypeError, ValueError):
return (None, None)
row = (await db.execute(select(LocalPreset).where(LocalPreset.id == local_id))).scalar_one_or_none()
if row is None:
return (None, None)
return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
async def _check_one_printer(
db: AsyncSession,
pipeline: SlicerPipeline,
printer: Printer,
printer_raw_status: dict | None,
) -> tuple[bool, tuple[EligibilityIssue, ...]]:
"""Run the per-printer eligibility checks. Returns ``(ok, issues)`` so the
caller can flatten them into either a single-printer or class-targeting
report. Pulled out of the original entry function so PR C's class branch
can reuse it for each candidate printer."""
issues: list[EligibilityIssue] = []
if not printer.is_active:
issues.append(EligibilityIssue(kind="printer_disabled"))
if not printer_raw_status or not printer_raw_status.get("connected"):
issues.append(EligibilityIssue(kind="printer_offline"))
return (not issues, tuple(issues))
try:
filament_refs = json.loads(pipeline.filament_presets_json or "[]")
except (json.JSONDecodeError, TypeError):
filament_refs = []
ams_slots = _ams_slots(printer_raw_status.get("raw_data") or {})
for slot_index, ref in enumerate(filament_refs):
if not isinstance(ref, dict):
continue
source = ref.get("source", "")
preset_id = ref.get("id", "")
expected_type, expected_colour = await _expected_filament(db, source, str(preset_id))
if expected_type is None:
issues.append(
EligibilityIssue(
kind="filament_unverified",
slot_index=slot_index,
expected=f"{source}:{preset_id}",
)
)
continue
if slot_index >= len(ams_slots):
issues.append(
EligibilityIssue(
kind="ams_slot_missing",
slot_index=slot_index,
expected=expected_type,
)
)
continue
actual_type, actual_colour = ams_slots[slot_index]
if expected_type and actual_type and expected_type != actual_type:
issues.append(
EligibilityIssue(
kind="filament_type_mismatch",
slot_index=slot_index,
expected=expected_type,
actual=actual_type or "(empty)",
)
)
continue
if expected_colour and actual_colour and expected_colour != actual_colour:
issues.append(
EligibilityIssue(
kind="filament_color_mismatch",
slot_index=slot_index,
expected=expected_colour,
actual=actual_colour,
)
)
# ``filament_unverified`` is informational — doesn't flip ok=False.
blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
return (not blocking_issues, tuple(issues))
async def check_pipeline_eligibility(
db: AsyncSession,
pipeline: SlicerPipeline,
printer_raw_status: dict | None = None,
*,
status_lookup: object = None,
) -> EligibilityReport:
"""Build the eligibility report.
Two calling shapes, chosen by ``pipeline.target_kind``:
- ``specific_printer``: ``printer_raw_status`` carries the live
``PrinterState`` dict (``connected`` + ``raw_data``) for the pinned
target_printer_id. PR B signature, preserved.
- ``printer_class``: ``status_lookup`` is a callable
``(printer_id) -> dict | None`` that the matcher calls for each
printer whose model matches ``pipeline.target_model_class``.
"""
# PR A pipelines default target_kind to 'printer_class' but PR B and
# earlier UI only let users pin a specific_printer; treat
# ``target_printer_id is not None`` as the source of truth for the
# specific-printer path until the editor exposes target_kind explicitly.
if pipeline.target_printer_id is not None or pipeline.target_kind == "specific_printer":
# Specific-printer branch (PR B parity).
if pipeline.target_printer_id is None:
return EligibilityReport(
ok=False,
target_kind="specific_printer",
target_printer_id=None,
target_printer_name=None,
target_model_class=None,
issues=(EligibilityIssue(kind="printer_not_set"),),
)
printer = (
await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))
).scalar_one_or_none()
if printer is None:
return EligibilityReport(
ok=False,
target_kind="specific_printer",
target_printer_id=pipeline.target_printer_id,
target_printer_name=None,
target_model_class=None,
issues=(EligibilityIssue(kind="printer_not_found"),),
)
ok, issues = await _check_one_printer(db, pipeline, printer, printer_raw_status)
return EligibilityReport(
ok=ok,
target_kind="specific_printer",
target_printer_id=printer.id,
target_printer_name=printer.name,
target_model_class=None,
issues=issues,
)
# Class-targeting branch (PR C).
if not pipeline.target_model_class:
return EligibilityReport(
ok=False,
target_kind="printer_class",
target_printer_id=None,
target_printer_name=None,
target_model_class=None,
issues=(EligibilityIssue(kind="class_not_set"),),
)
# Without regard to case, as the queue and the scheduler match a model
candidates = (
(
await db.execute(
select(Printer).where(func.lower(Printer.model) == (pipeline.target_model_class or "").lower())
)
)
.scalars()
.all()
)
if not candidates:
return EligibilityReport(
ok=False,
target_kind="printer_class",
target_printer_id=None,
target_printer_name=None,
target_model_class=pipeline.target_model_class,
issues=(
EligibilityIssue(
kind="no_class_matches",
expected=pipeline.target_model_class,
),
),
)
reports: list[PerPrinterReport] = []
if status_lookup is None:
# Treat all printers as offline when no lookup was provided — keeps
# the matcher pure-ish for unit tests.
for printer in candidates:
ok, issues = await _check_one_printer(db, pipeline, printer, None)
reports.append(
PerPrinterReport(
printer_id=printer.id,
printer_name=printer.name,
ok=ok,
issues=issues,
)
)
else:
for printer in candidates:
raw = status_lookup(printer.id)
ok, issues = await _check_one_printer(db, pipeline, printer, raw)
reports.append(
PerPrinterReport(
printer_id=printer.id,
printer_name=printer.name,
ok=ok,
issues=issues,
)
)
any_ok = any(r.ok for r in reports)
return EligibilityReport(
ok=any_ok,
target_kind="printer_class",
target_printer_id=None,
target_printer_name=None,
target_model_class=pipeline.target_model_class,
issues=(),
printer_reports=tuple(reports),
)