| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360 |
- """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 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
- 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, ...] = ()
- # Same equivalence map as print_scheduler._canonical_filament_type but kept
- # local so this module has no upward dependency on the scheduler. Mirrors the
- # scheduler's behaviour: BBL-prefixed product names normalise to the base type
- # (e.g. "PLA Basic" → "PLA"). When the scheduler's map gets a new alias, this
- # one needs the same entry.
- _FILAMENT_EQUIV_MAP = {
- "PLA": "PLA",
- "PLA BASIC": "PLA",
- "PLA MATTE": "PLA",
- "PLA SILK": "PLA",
- "PLA PRO": "PLA",
- "PLA TOUGH": "PLA",
- "PETG": "PETG",
- "PETG HF": "PETG",
- "PETG BASIC": "PETG",
- "PETG TRANSLUCENT": "PETG",
- "ABS": "ABS",
- "ASA": "ASA",
- "TPU": "TPU",
- "TPU 95A": "TPU",
- "PC": "PC",
- "PA": "PA",
- "PA-CF": "PA",
- "PVA": "PVA",
- }
- def _canonical(ftype: str) -> str:
- upper = (ftype or "").strip().upper()
- return _FILAMENT_EQUIV_MAP.get(upper, upper)
- 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"),),
- )
- candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).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),
- )
|