pipeline_eligibility.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. """Eligibility matcher for Slicer Pipeline runs (#1425 PR B).
  2. Given a pipeline + the user's pinned target printer, this returns a structured
  3. report of issues the operator should resolve before running. The frontend
  4. displays the report; the user can ``Run anyway`` to proceed (lenient policy —
  5. the print may still fail at the printer, but Bambuddy isn't going to refuse
  6. the click).
  7. Issue kinds (pinned for tests + i18n keys):
  8. - printer_not_set — pipeline has no target_printer_id
  9. - printer_not_found — target_printer_id points at a deleted/missing row
  10. - printer_disabled — Printer.is_active is False (#1476)
  11. - printer_offline — MQTT not connected
  12. - filament_type_mismatch — AMS slot loaded with wrong filament type
  13. - filament_color_mismatch — type matches, colour differs
  14. - ams_slot_missing — pipeline expects N filament slots but AMS exposes fewer
  15. - filament_unverified — pipeline filament preset is a non-local tier we
  16. can't statically read (cloud / orca_cloud / standard);
  17. the run will proceed, but the operator should
  18. double-check
  19. The matcher is a pure-ish function over (pipeline, printer row, live AMS state,
  20. local-preset dict) so unit tests can drive it with fixtures without spinning up
  21. MQTT. The route handler is the only place that talks to ``printer_manager``.
  22. """
  23. from __future__ import annotations
  24. import json
  25. from dataclasses import dataclass
  26. from typing import Literal
  27. from sqlalchemy import select
  28. from sqlalchemy.ext.asyncio import AsyncSession
  29. from backend.app.models.local_preset import LocalPreset
  30. from backend.app.models.printer import Printer
  31. from backend.app.models.slicer_pipeline import SlicerPipeline
  32. IssueKind = Literal[
  33. "printer_not_set",
  34. "printer_not_found",
  35. "printer_disabled",
  36. "printer_offline",
  37. "filament_type_mismatch",
  38. "filament_color_mismatch",
  39. "ams_slot_missing",
  40. "filament_unverified",
  41. "no_class_matches",
  42. "class_not_set",
  43. ]
  44. @dataclass(frozen=True)
  45. class EligibilityIssue:
  46. kind: IssueKind
  47. slot_index: int | None = None
  48. expected: str | None = None
  49. actual: str | None = None
  50. @dataclass(frozen=True)
  51. class PerPrinterReport:
  52. """One row of the class-targeting eligibility breakdown."""
  53. printer_id: int
  54. printer_name: str
  55. ok: bool
  56. issues: tuple[EligibilityIssue, ...]
  57. @dataclass(frozen=True)
  58. class EligibilityReport:
  59. ok: bool
  60. target_kind: Literal["specific_printer", "printer_class"]
  61. target_printer_id: int | None
  62. target_printer_name: str | None
  63. target_model_class: str | None
  64. issues: tuple[EligibilityIssue, ...]
  65. printer_reports: tuple[PerPrinterReport, ...] = ()
  66. # Same equivalence map as print_scheduler._canonical_filament_type but kept
  67. # local so this module has no upward dependency on the scheduler. Mirrors the
  68. # scheduler's behaviour: BBL-prefixed product names normalise to the base type
  69. # (e.g. "PLA Basic" → "PLA"). When the scheduler's map gets a new alias, this
  70. # one needs the same entry.
  71. _FILAMENT_EQUIV_MAP = {
  72. "PLA": "PLA",
  73. "PLA BASIC": "PLA",
  74. "PLA MATTE": "PLA",
  75. "PLA SILK": "PLA",
  76. "PLA PRO": "PLA",
  77. "PLA TOUGH": "PLA",
  78. "PETG": "PETG",
  79. "PETG HF": "PETG",
  80. "PETG BASIC": "PETG",
  81. "PETG TRANSLUCENT": "PETG",
  82. "ABS": "ABS",
  83. "ASA": "ASA",
  84. "TPU": "TPU",
  85. "TPU 95A": "TPU",
  86. "PC": "PC",
  87. "PA": "PA",
  88. "PA-CF": "PA",
  89. "PVA": "PVA",
  90. }
  91. def _canonical(ftype: str) -> str:
  92. upper = (ftype or "").strip().upper()
  93. return _FILAMENT_EQUIV_MAP.get(upper, upper)
  94. def _normalise_colour(colour: str | None) -> str:
  95. if not colour:
  96. return ""
  97. return colour.replace("#", "").lower()[:6]
  98. def _ams_slots(raw_data: dict) -> list[tuple[str, str]]:
  99. """Flatten AMS + external spool into ``[(type, colour_hex6), ...]`` in slot
  100. order. Uses the same field shape as print_scheduler._check_required_filaments.
  101. """
  102. out: list[tuple[str, str]] = []
  103. for ams_unit in raw_data.get("ams") or []:
  104. for tray in ams_unit.get("tray") or []:
  105. tray_type = tray.get("tray_type") or ""
  106. tray_colour = tray.get("tray_color") or ""
  107. out.append((_canonical(tray_type), _normalise_colour(tray_colour)))
  108. for vt in raw_data.get("vt_tray") or []:
  109. vt_type = vt.get("tray_type") or ""
  110. vt_colour = vt.get("tray_color") or ""
  111. out.append((_canonical(vt_type), _normalise_colour(vt_colour)))
  112. return out
  113. async def _expected_filament(
  114. db: AsyncSession,
  115. source: str,
  116. preset_id: str,
  117. ) -> tuple[str | None, str | None]:
  118. """Return ``(canonical_type, normalised_colour)`` for a pipeline filament
  119. slot's PresetRef, or ``(None, None)`` when the preset can't be resolved
  120. statically (cloud / orca_cloud / standard — read at slice time, not here).
  121. """
  122. if source != "local":
  123. # Cloud / orca_cloud / standard: surface as ``filament_unverified``
  124. # in the report, the matcher decides.
  125. return (None, None)
  126. try:
  127. local_id = int(preset_id)
  128. except (TypeError, ValueError):
  129. return (None, None)
  130. row = (await db.execute(select(LocalPreset).where(LocalPreset.id == local_id))).scalar_one_or_none()
  131. if row is None:
  132. return (None, None)
  133. return (_canonical(row.filament_type or ""), _normalise_colour(row.default_filament_colour))
  134. async def _check_one_printer(
  135. db: AsyncSession,
  136. pipeline: SlicerPipeline,
  137. printer: Printer,
  138. printer_raw_status: dict | None,
  139. ) -> tuple[bool, tuple[EligibilityIssue, ...]]:
  140. """Run the per-printer eligibility checks. Returns ``(ok, issues)`` so the
  141. caller can flatten them into either a single-printer or class-targeting
  142. report. Pulled out of the original entry function so PR C's class branch
  143. can reuse it for each candidate printer."""
  144. issues: list[EligibilityIssue] = []
  145. if not printer.is_active:
  146. issues.append(EligibilityIssue(kind="printer_disabled"))
  147. if not printer_raw_status or not printer_raw_status.get("connected"):
  148. issues.append(EligibilityIssue(kind="printer_offline"))
  149. return (not issues, tuple(issues))
  150. try:
  151. filament_refs = json.loads(pipeline.filament_presets_json or "[]")
  152. except (json.JSONDecodeError, TypeError):
  153. filament_refs = []
  154. ams_slots = _ams_slots(printer_raw_status.get("raw_data") or {})
  155. for slot_index, ref in enumerate(filament_refs):
  156. if not isinstance(ref, dict):
  157. continue
  158. source = ref.get("source", "")
  159. preset_id = ref.get("id", "")
  160. expected_type, expected_colour = await _expected_filament(db, source, str(preset_id))
  161. if expected_type is None:
  162. issues.append(
  163. EligibilityIssue(
  164. kind="filament_unverified",
  165. slot_index=slot_index,
  166. expected=f"{source}:{preset_id}",
  167. )
  168. )
  169. continue
  170. if slot_index >= len(ams_slots):
  171. issues.append(
  172. EligibilityIssue(
  173. kind="ams_slot_missing",
  174. slot_index=slot_index,
  175. expected=expected_type,
  176. )
  177. )
  178. continue
  179. actual_type, actual_colour = ams_slots[slot_index]
  180. if expected_type and actual_type and expected_type != actual_type:
  181. issues.append(
  182. EligibilityIssue(
  183. kind="filament_type_mismatch",
  184. slot_index=slot_index,
  185. expected=expected_type,
  186. actual=actual_type or "(empty)",
  187. )
  188. )
  189. continue
  190. if expected_colour and actual_colour and expected_colour != actual_colour:
  191. issues.append(
  192. EligibilityIssue(
  193. kind="filament_color_mismatch",
  194. slot_index=slot_index,
  195. expected=expected_colour,
  196. actual=actual_colour,
  197. )
  198. )
  199. # ``filament_unverified`` is informational — doesn't flip ok=False.
  200. blocking_issues = [i for i in issues if i.kind != "filament_unverified"]
  201. return (not blocking_issues, tuple(issues))
  202. async def check_pipeline_eligibility(
  203. db: AsyncSession,
  204. pipeline: SlicerPipeline,
  205. printer_raw_status: dict | None = None,
  206. *,
  207. status_lookup: object = None,
  208. ) -> EligibilityReport:
  209. """Build the eligibility report.
  210. Two calling shapes, chosen by ``pipeline.target_kind``:
  211. - ``specific_printer``: ``printer_raw_status`` carries the live
  212. ``PrinterState`` dict (``connected`` + ``raw_data``) for the pinned
  213. target_printer_id. PR B signature, preserved.
  214. - ``printer_class``: ``status_lookup`` is a callable
  215. ``(printer_id) -> dict | None`` that the matcher calls for each
  216. printer whose model matches ``pipeline.target_model_class``.
  217. """
  218. # PR A pipelines default target_kind to 'printer_class' but PR B and
  219. # earlier UI only let users pin a specific_printer; treat
  220. # ``target_printer_id is not None`` as the source of truth for the
  221. # specific-printer path until the editor exposes target_kind explicitly.
  222. if pipeline.target_printer_id is not None or pipeline.target_kind == "specific_printer":
  223. # Specific-printer branch (PR B parity).
  224. if pipeline.target_printer_id is None:
  225. return EligibilityReport(
  226. ok=False,
  227. target_kind="specific_printer",
  228. target_printer_id=None,
  229. target_printer_name=None,
  230. target_model_class=None,
  231. issues=(EligibilityIssue(kind="printer_not_set"),),
  232. )
  233. printer = (
  234. await db.execute(select(Printer).where(Printer.id == pipeline.target_printer_id))
  235. ).scalar_one_or_none()
  236. if printer is None:
  237. return EligibilityReport(
  238. ok=False,
  239. target_kind="specific_printer",
  240. target_printer_id=pipeline.target_printer_id,
  241. target_printer_name=None,
  242. target_model_class=None,
  243. issues=(EligibilityIssue(kind="printer_not_found"),),
  244. )
  245. ok, issues = await _check_one_printer(db, pipeline, printer, printer_raw_status)
  246. return EligibilityReport(
  247. ok=ok,
  248. target_kind="specific_printer",
  249. target_printer_id=printer.id,
  250. target_printer_name=printer.name,
  251. target_model_class=None,
  252. issues=issues,
  253. )
  254. # Class-targeting branch (PR C).
  255. if not pipeline.target_model_class:
  256. return EligibilityReport(
  257. ok=False,
  258. target_kind="printer_class",
  259. target_printer_id=None,
  260. target_printer_name=None,
  261. target_model_class=None,
  262. issues=(EligibilityIssue(kind="class_not_set"),),
  263. )
  264. candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).scalars().all()
  265. if not candidates:
  266. return EligibilityReport(
  267. ok=False,
  268. target_kind="printer_class",
  269. target_printer_id=None,
  270. target_printer_name=None,
  271. target_model_class=pipeline.target_model_class,
  272. issues=(
  273. EligibilityIssue(
  274. kind="no_class_matches",
  275. expected=pipeline.target_model_class,
  276. ),
  277. ),
  278. )
  279. reports: list[PerPrinterReport] = []
  280. if status_lookup is None:
  281. # Treat all printers as offline when no lookup was provided — keeps
  282. # the matcher pure-ish for unit tests.
  283. for printer in candidates:
  284. ok, issues = await _check_one_printer(db, pipeline, printer, None)
  285. reports.append(
  286. PerPrinterReport(
  287. printer_id=printer.id,
  288. printer_name=printer.name,
  289. ok=ok,
  290. issues=issues,
  291. )
  292. )
  293. else:
  294. for printer in candidates:
  295. raw = status_lookup(printer.id)
  296. ok, issues = await _check_one_printer(db, pipeline, printer, raw)
  297. reports.append(
  298. PerPrinterReport(
  299. printer_id=printer.id,
  300. printer_name=printer.name,
  301. ok=ok,
  302. issues=issues,
  303. )
  304. )
  305. any_ok = any(r.ok for r in reports)
  306. return EligibilityReport(
  307. ok=any_ok,
  308. target_kind="printer_class",
  309. target_printer_id=None,
  310. target_printer_name=None,
  311. target_model_class=pipeline.target_model_class,
  312. issues=(),
  313. printer_reports=tuple(reports),
  314. )