filament_deficit.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933
  1. """Filament-deficit check used by every queue dispatch path.
  2. The PrintModal warns when an assigned spool can't satisfy a print's per-slot
  3. filament weight (``Pre-print checks now also warn when the spool has
  4. insufficient material`` — #720). That check only runs when the user clicks
  5. "Print" inside PrintModal; ``QueuePage`` Play button, ``start_queue_item``
  6. route, and the VP intake + scheduler auto-dispatch path all skip it (#1496).
  7. This module is the single source of truth for the check. Both the route
  8. handler (``POST /print-queue/{id}/start``) and the dispatch scheduler call
  9. ``compute_deficit_for_queue_item`` against live spool state.
  10. Design notes:
  11. * The 3MF parser is the same one used by PrintModal: per-slot ``used_grams``
  12. comes from ``extract_filament_requirements`` (#1188's filament-overrides
  13. pipeline) or — when the item points at an unsliced library file — falls
  14. through to the file's archive copy. Anything that yields no requirements
  15. is treated as "no deficit" so a malformed or stripped 3MF never blocks.
  16. * Both internal-inventory and Spoolman modes are covered. Internal mode
  17. resolves via ``SpoolAssignment`` joined to ``Spool`` (``label_weight``
  18. minus ``weight_used``). Spoolman mode resolves via
  19. ``SpoolmanSlotAssignment`` then ``SpoolmanClient.get_spool`` for the live
  20. remaining weight; if Spoolman is unreachable we return no deficit rather
  21. than wedge the queue on a flaky network call.
  22. * The ``disable_filament_warnings`` user setting is respected at the
  23. service boundary — callers do not have to know about it.
  24. """
  25. from __future__ import annotations
  26. import json
  27. import logging
  28. from collections import defaultdict
  29. from dataclasses import dataclass
  30. from pathlib import Path
  31. from sqlalchemy import select
  32. from sqlalchemy.ext.asyncio import AsyncSession
  33. from sqlalchemy.orm import selectinload
  34. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
  35. from backend.app.core.config import settings as app_settings
  36. from backend.app.models.print_queue import PrintQueueItem
  37. from backend.app.models.spool_assignment import SpoolAssignment
  38. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  39. from backend.app.services.filament_requirements import extract_filament_requirements
  40. from backend.app.utils.filament_ids import normalize_slicer_filament
  41. logger = logging.getLogger(__name__)
  42. @dataclass(frozen=True)
  43. class FilamentDeficit:
  44. """One slot's filament shortfall."""
  45. slot_id: int
  46. ams_id: int | None
  47. tray_id: int | None
  48. filament_type: str
  49. required_grams: float
  50. remaining_grams: float | None # None = could not determine
  51. def to_dict(self) -> dict:
  52. return {
  53. "slot_id": self.slot_id,
  54. "ams_id": self.ams_id,
  55. "tray_id": self.tray_id,
  56. "filament_type": self.filament_type,
  57. "required_grams": self.required_grams,
  58. "remaining_grams": self.remaining_grams,
  59. }
  60. def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
  61. """Inverse of ``ams_id * 4 + tray_id`` — matches ``usage_tracker``."""
  62. if global_tray_id >= 254:
  63. return (255, global_tray_id - 254)
  64. if global_tray_id >= 128:
  65. return (global_tray_id, 0)
  66. return (global_tray_id // 4, global_tray_id % 4)
  67. def _ams_key_to_global(ams_id: int, tray_id: int) -> int:
  68. """Inverse of ``_global_to_ams_key``.
  69. Mirrors the frontend ``getGlobalTrayId``: external / VT slots (``ams_id``
  70. 255) land at ``254 + tray_id``, AMS-HT units (``ams_id`` >= 128) use the
  71. unit id directly, regular AMS slots use ``ams_id * 4 + tray_id``.
  72. """
  73. if ams_id >= 255:
  74. return 254 + tray_id
  75. if ams_id >= 128:
  76. return ams_id
  77. return ams_id * 4 + tray_id
  78. def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
  79. """Locate the 3MF file backing this queue item (archive or library).
  80. ``LibraryFile.file_path`` is stored relative to ``base_dir`` (rows written
  81. before that convention hold absolute paths, which is why every reader
  82. guards on ``is_absolute``). Resolving a relative one against the process
  83. working directory finds nothing, and a source that cannot be found is
  84. treated as "nothing to verify" — so this check silently passed every
  85. library-backed item, which is every Slicer Pipeline job and everything
  86. queued from the Library page (#2779).
  87. """
  88. if item.archive is not None and item.archive.file_path:
  89. return app_settings.base_dir / item.archive.file_path
  90. if item.library_file is not None and item.library_file.file_path:
  91. library_path = Path(item.library_file.file_path)
  92. if library_path.is_absolute():
  93. return library_path
  94. # SEC-PATH-OK: file_path is DB-stored and generated by the Library
  95. # ingest (archive/library/files/<uuid>.<ext>), never request input. The
  96. # same value already resolves the file for upload in print_queue.py and
  97. # print_scheduler.py — this check reads what the printer is about to be
  98. # sent, so it must resolve it identically.
  99. return app_settings.base_dir / item.library_file.file_path
  100. return None
  101. async def _spoolman_remaining_grams(spoolman_spool_id: int) -> float | None:
  102. """Live remaining grams for a Spoolman spool, or None if unavailable."""
  103. try:
  104. from backend.app.services.spoolman import (
  105. SpoolmanClientError,
  106. SpoolmanNotFoundError,
  107. get_spoolman_client,
  108. )
  109. except ImportError:
  110. return None
  111. try:
  112. client = await get_spoolman_client()
  113. if client is None:
  114. return None
  115. spool = await client.get_spool(spoolman_spool_id)
  116. except (SpoolmanNotFoundError, SpoolmanClientError):
  117. return None
  118. except Exception as e:
  119. logger.debug("Spoolman fetch failed for spool %s: %s", spoolman_spool_id, e)
  120. return None
  121. if not spool:
  122. return None
  123. # Spoolman exposes either an absolute remaining_weight, or used_weight +
  124. # the spool's net weight (initial_weight, else filament.weight; #3194).
  125. # Either is sufficient — prefer remaining_weight when present (the user
  126. # may have overridden it).
  127. remaining = spool.get("remaining_weight")
  128. if isinstance(remaining, (int, float)) and remaining >= 0:
  129. return float(remaining)
  130. used = spool.get("used_weight")
  131. total = spoolman_net_weight(spool)
  132. if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
  133. return max(0.0, float(total) - float(used))
  134. return None
  135. async def _is_spoolman_mode(db: AsyncSession) -> bool:
  136. """Check whether the user has opted in to Spoolman inventory mode."""
  137. try:
  138. from backend.app.api.routes.settings import get_setting
  139. spoolman_enabled = await get_setting(db, "spoolman_enabled")
  140. return bool(spoolman_enabled) and spoolman_enabled.lower() == "true"
  141. except Exception:
  142. return False
  143. async def _warnings_disabled(db: AsyncSession) -> bool:
  144. """Honour the ``disable_filament_warnings`` setting (#720)."""
  145. try:
  146. from backend.app.api.routes.settings import get_setting
  147. disabled = await get_setting(db, "disable_filament_warnings")
  148. return bool(disabled) and disabled.lower() == "true"
  149. except Exception:
  150. return False
  151. def _normalize_color_for_id(raw: str | None) -> str:
  152. """Canonicalise a hex colour for identity comparison.
  153. Strips the leading ``#``, uppercases, and drops the alpha channel when
  154. the hex is 8 chars long (``RRGGBBAA``) so a fully-opaque 8-char hex
  155. matches a 6-char hex of the same RGB. Empty / None → empty string.
  156. Anything that is not a string reads as "no colour" rather than raising.
  157. In Spoolman mode ``raw`` comes straight off the wire as
  158. ``filament.color_hex``, and this runs on the dispatch path — a record
  159. holding a number there would otherwise fail a queue start rather than
  160. merely fail to pool.
  161. """
  162. if not isinstance(raw, str):
  163. raw = None
  164. s = (raw or "").strip().lstrip("#").upper()
  165. if len(s) == 8: # RRGGBBAA → strip alpha
  166. s = s[:6]
  167. return s
  168. def _material_identity_internal(spool) -> str:
  169. """Strict same-material key for backup-peer matching in internal mode.
  170. Requires a Bambu filament preset ID (``slicer_filament``, e.g. ``GFA00``)
  171. AND a matching colour. The preset identifies the filament profile (PETG
  172. HF, PLA Basic, etc.) — same hot-end behaviour — but the firmware's
  173. switch logic also requires the spool to be the same colour (otherwise
  174. every PETG HF spool would back every other PETG HF spool regardless of
  175. colour, which would dye prints mid-run). Spools without a preset
  176. (user-tagged / non-Bambu) get a per-spool unique key so they NEVER
  177. pair with anything else; without the Bambu preset the firmware can't
  178. trust the backup decision.
  179. """
  180. preset = (spool.slicer_filament or "").strip() if spool else ""
  181. if preset:
  182. # The field holds a filament_id from an RFID scan ("GFA10") or a
  183. # setting_id from the preset picker ("GFSA10_02"); both name the same
  184. # filament, so compare the filament_id (#1824).
  185. filament_id = normalize_slicer_filament(preset)[0] or preset
  186. color = _normalize_color_for_id(spool.rgba if spool else None)
  187. return f"preset:{filament_id}|color:{color}"
  188. # Unique-per-spool key prevents grouping. Use the spool's primary key so
  189. # the same spool always resolves to the same key within a request.
  190. spool_id = getattr(spool, "id", None) if spool else None
  191. return f"unmatched:{spool_id}"
  192. def _material_identity_spoolman(spool: dict | None) -> str:
  193. """Strict same-material key for backup-peer matching in Spoolman mode.
  194. Two spools pair only when they reference the same Spoolman ``filament``
  195. catalog entry (same ``filament.id``) AND share the same colour. The
  196. catalog entry pins the profile (PETG HF / PLA Basic / ...); the colour
  197. pins the variant. Spools without a resolvable filament id get a
  198. per-spool unique key so they never pair.
  199. """
  200. if not isinstance(spool, dict) or not spool:
  201. return "unmatched:none"
  202. # Both are free-form JSON off the Spoolman API, and this is the dispatch
  203. # path: a wrongly-typed member must cost the slot its pool, never the
  204. # queue its start.
  205. filament = spool.get("filament") or {}
  206. if not isinstance(filament, dict):
  207. filament = {}
  208. fil_id = filament.get("id")
  209. if isinstance(fil_id, (int, str)) and str(fil_id).strip():
  210. # Prefer the per-spool override colour when set (Spoolman lets the user
  211. # tag a spool with a colour distinct from the filament catalog
  212. # default); fall back to the filament catalog colour.
  213. color = _normalize_color_for_id(
  214. (spool.get("color_hex") if isinstance(spool.get("color_hex"), str) else None) or filament.get("color_hex")
  215. )
  216. return f"filament:{fil_id}|color:{color}"
  217. spool_id = spool.get("id")
  218. return f"unmatched:{spool_id}"
  219. def _live_tray_keys(printer_id: int) -> dict[int, str]:
  220. """Pooling key per ``global_tray_id``, from what the printer reports per tray.
  221. AMS Filament Backup is the firmware's decision, and it makes it on the
  222. tray's own ``tray_info_idx`` and colour. The spool rows bound to two slots
  223. can disagree about that while the printer does not: an RFID-created spool
  224. stores "GFA10", one set up from a user preset "PFUS..." (#1824). So a slot
  225. the printer reports is keyed on the report, in the same format
  226. ``_material_identity_internal`` uses; a tray with nothing in it or no
  227. preset gets a key of its own, so it never pairs. Slots missing from the
  228. report fall back to the bound spool.
  229. """
  230. try:
  231. from backend.app.services.printer_manager import printer_manager
  232. except ImportError:
  233. return {}
  234. state = printer_manager.get_status(printer_id)
  235. raw = getattr(state, "raw_data", None) if state is not None else None
  236. if not isinstance(raw, dict):
  237. return {}
  238. keys: dict[int, str] = {}
  239. def _add(global_tray_id: int, tray: dict) -> None:
  240. preset = tray.get("tray_info_idx")
  241. preset = preset.strip() if isinstance(preset, str) else ""
  242. if tray.get("tray_type") and preset:
  243. keys[global_tray_id] = f"preset:{preset}|color:{_normalize_color_for_id(tray.get('tray_color'))}"
  244. else:
  245. keys[global_tray_id] = f"unmatched:tray:{global_tray_id}"
  246. # Free-form MQTT payload on the dispatch path: a malformed unit or tray
  247. # costs that slot its live key, never the check.
  248. units = raw.get("ams") or []
  249. if isinstance(units, dict): # also stored as the push's {"ams": [...]}
  250. units = units.get("ams") or []
  251. for unit in units:
  252. if not isinstance(unit, dict):
  253. continue
  254. for tray in unit.get("tray") or []:
  255. if not isinstance(tray, dict):
  256. continue
  257. try:
  258. ams_id, tray_id = int(unit.get("id", "")), int(tray.get("id", ""))
  259. except (TypeError, ValueError):
  260. continue
  261. _add(_ams_key_to_global(ams_id, tray_id), tray)
  262. vt_trays = raw.get("vt_tray") or []
  263. for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
  264. if not isinstance(tray, dict):
  265. continue
  266. try:
  267. global_tray_id = int(tray.get("id", 254))
  268. except (TypeError, ValueError):
  269. continue
  270. _add(global_tray_id, tray)
  271. return keys
  272. def _extruder_side_for_ams(
  273. ams_id: int,
  274. ams_extruder_map: dict[str, int],
  275. is_dual_extruder: bool,
  276. ) -> int:
  277. """Resolve the extruder index (0=right, 1=left) for a given AMS unit.
  278. Single-extruder printers collapse everything to 0. On dual-extruder
  279. printers (H2D / H2C / X2D), the firmware can't cross extruders even with
  280. AMS Filament Backup ON, so the pool must be scoped per-side.
  281. """
  282. if not is_dual_extruder:
  283. return 0
  284. return int(ams_extruder_map.get(str(ams_id), 0))
  285. def _parse_ams_mapping(raw: str | None) -> list[int] | None:
  286. if not raw:
  287. return None
  288. try:
  289. parsed = json.loads(raw)
  290. except (json.JSONDecodeError, TypeError):
  291. return None
  292. if not isinstance(parsed, list):
  293. return None
  294. return [v for v in parsed if isinstance(v, int)]
  295. async def _get_printer_backup_context(
  296. printer_id: int,
  297. ) -> tuple[bool, dict[str, int], bool]:
  298. """Return ``(backup_on, ams_extruder_map, is_dual_extruder)`` for the printer.
  299. Read from the live MQTT state via ``printer_manager`` (no DB round-trip).
  300. Defaults conservatively to ``backup_on=False`` when the state is missing
  301. or the printer is offline — same fallback as today (per-slot deficit
  302. accounting), so an offline printer is never treated as backup-capable.
  303. """
  304. try:
  305. from backend.app.services.printer_manager import printer_manager
  306. from backend.app.utils.printer_models import is_dual_nozzle_model
  307. except ImportError:
  308. return False, {}, False
  309. state = printer_manager.get_status(printer_id)
  310. if state is None:
  311. return False, {}, False
  312. backup_on = state.ams_filament_backup is True
  313. ams_extruder_map = dict(state.ams_extruder_map or {})
  314. model = printer_manager.get_model(printer_id)
  315. is_dual = bool(model and is_dual_nozzle_model(model))
  316. return backup_on, ams_extruder_map, is_dual
  317. @dataclass(frozen=True)
  318. class SlotSpoolIdentity:
  319. """How the spool bound to a slot should be *named*, as opposed to matched.
  320. The printer cannot supply this and never will. A tray record carries no
  321. brand field at all, and ``tray_sub_brands`` stays empty for anything that
  322. isn't a Bambu spool, so a client naming a slot from telemetry alone has
  323. only the type and the colour hex to work with — and turns that hex into
  324. whichever catalogue colour happens to share it. A Devil Design PLA Basic
  325. Orange the operator assigned in Bambuddy reads back as "PLA (Sunflower
  326. Yellow)", because Bambu sell a Sunflower Yellow at the same ``FEC600``.
  327. Only the assignment knows the answer, which is why it is served alongside
  328. the pooling key rather than left to the client to resolve: the identity
  329. rule differs per inventory mode, and the printer card and the print dialog
  330. disagreeing about what is in a slot is the bug this exists to close.
  331. Purely descriptive — nothing here takes part in matching, which stays on
  332. the printer's own telemetry so the dialog and the dispatcher cannot draw
  333. different conclusions from the same slot.
  334. """
  335. brand: str | None
  336. material: str | None
  337. subtype: str | None
  338. color_name: str | None
  339. rgba: str | None
  340. # The rest of the spool's colour, for drawing it rather than naming it
  341. # (#3159). A tray record carries one hex and nothing else, so a two-tone
  342. # or glittery spool renders as its base colour unless the binding says
  343. # otherwise -- and on a farm of third-party spools that is exactly the
  344. # swatch somebody is comparing against the slicer's colour. Same shape the
  345. # inventory row stores: ``extra_colors`` a comma-separated hex list,
  346. # ``effect_type`` one of the client's FilamentEffect names.
  347. extra_colors: str | None = None
  348. effect_type: str | None = None
  349. def to_dict(self) -> dict:
  350. return {
  351. "brand": self.brand,
  352. "material": self.material,
  353. "subtype": self.subtype,
  354. "color_name": self.color_name,
  355. "rgba": self.rgba,
  356. "extra_colors": self.extra_colors,
  357. "effect_type": self.effect_type,
  358. }
  359. def _clean(value) -> str | None:
  360. """Trim a display field, collapsing blanks to None so the client can skip it."""
  361. text = str(value).strip() if value is not None else ""
  362. return text or None
  363. def _identity_from_internal(spool) -> SlotSpoolIdentity:
  364. """Display identity from an internal-inventory ``Spool`` row."""
  365. return SlotSpoolIdentity(
  366. brand=_clean(spool.brand),
  367. material=_clean(spool.material),
  368. subtype=_clean(spool.subtype),
  369. color_name=_clean(spool.color_name),
  370. rgba=_clean(spool.rgba),
  371. extra_colors=_clean(spool.extra_colors),
  372. effect_type=_clean(spool.effect_type),
  373. )
  374. def _identity_from_spoolman(spool_dict: dict) -> SlotSpoolIdentity | None:
  375. """Display identity from a raw Spoolman spool dict, or None if unreadable.
  376. Goes through ``_map_spoolman_spool`` rather than reading the dict directly:
  377. brand lives on the nested vendor, subtype is the filament name with its
  378. material prefix stripped, and ``color_name`` has a three-step read order
  379. Spoolman itself has no field for. Re-deriving any of that here is how the
  380. two modes would drift apart.
  381. """
  382. # Broad on purpose. This is a name for a dropdown, and the caller is on the
  383. # dispatch path -- ``compute_deficit_for_queue_item`` runs it before every
  384. # queue start. ``_map_spoolman_spool`` walks a dozen nested fields off the
  385. # wire (``filament.vendor.name``, ``extra.tag``, ``filament.color_hex``) and
  386. # any of them arriving as the wrong type raises AttributeError rather than
  387. # ValueError, so a narrow catch here would turn one malformed Spoolman
  388. # record into a failed dispatch. Losing the name costs a fallback to
  389. # telemetry, which is what every slot did before this existed.
  390. try:
  391. mapped = _map_spoolman_spool(spool_dict)
  392. except Exception as exc: # noqa: BLE001 - display-only, must never block a dispatch
  393. logger.debug(
  394. "Spoolman spool %r has no usable display identity: %s",
  395. spool_dict.get("id") if isinstance(spool_dict, dict) else spool_dict,
  396. exc,
  397. )
  398. return None
  399. # Spoolman has no colour-name field, so `_map_spoolman_spool` synthesises
  400. # one from the subtype when nothing is stored -- which reads fine in an
  401. # inventory list ("PLA Basic") and badly as a colour ("Devil Design PLA
  402. # Basic (Basic)"). Drop it and let the client's catalogue lookup name the
  403. # hex, which is what an unnamed slot got before this existed.
  404. color_name = None if mapped.get("color_name_is_synthesized") else _clean(mapped.get("color_name"))
  405. return SlotSpoolIdentity(
  406. brand=_clean(mapped.get("brand")),
  407. material=_clean(mapped.get("material")),
  408. subtype=_clean(mapped.get("subtype")),
  409. color_name=color_name,
  410. rgba=_clean(mapped.get("rgba")),
  411. # `_map_spoolman_spool` builds `extra_colors` from Spoolman's own
  412. # multi-colour hexes and pins `effect_type` to None, because Spoolman
  413. # has no field for it -- see the note there.
  414. extra_colors=_clean(mapped.get("extra_colors")),
  415. effect_type=_clean(mapped.get("effect_type")),
  416. )
  417. @dataclass(frozen=True)
  418. class SlotMaterial:
  419. """One inventory-bound AMS slot: what's in it, how much is left, which side."""
  420. ams_id: int
  421. tray_id: int
  422. global_tray_id: int
  423. # Opaque grouping key. Two slots pool for AMS Filament Backup only when
  424. # their keys AND extruder sides match. Never parse it — the format is an
  425. # implementation detail of ``_live_tray_keys`` / ``_material_identity_*``.
  426. material_key: str
  427. remaining_grams: float
  428. extruder: int
  429. # Display-only; see SlotSpoolIdentity. None when the binding resolves to a
  430. # spool we cannot describe, which callers render from telemetry as before.
  431. spool: SlotSpoolIdentity | None = None
  432. # Per-kg rate a print off this spool is charged at; None when unpriced,
  433. # which the charge prices at the default rate. Display-only: the print
  434. # dialog's estimate, which priced Spoolman slots from the built-in table.
  435. cost_per_kg: float | None = None
  436. def to_dict(self) -> dict:
  437. return {
  438. "ams_id": self.ams_id,
  439. "tray_id": self.tray_id,
  440. "global_tray_id": self.global_tray_id,
  441. "material_key": self.material_key,
  442. "remaining_g": self.remaining_grams,
  443. "extruder": self.extruder,
  444. "spool": self.spool.to_dict() if self.spool else None,
  445. "cost_per_kg": self.cost_per_kg,
  446. }
  447. async def build_slot_materials(
  448. db: AsyncSession,
  449. printer_id: int,
  450. *,
  451. live_keys: dict[int, str] | None = None,
  452. ) -> list[SlotMaterial]:
  453. """Every inventory-bound slot on ``printer_id``, with identity and remaining grams.
  454. Mode-agnostic: internal inventory resolves via ``SpoolAssignment`` joined to
  455. ``Spool`` (``label_weight`` minus ``weight_used``), Spoolman mode via
  456. ``SpoolmanSlotAssignment`` plus a live ``get_spool`` fetch. Slots whose
  457. remaining weight can't be determined — no spool row, zero label weight,
  458. Spoolman unreachable — are omitted entirely rather than reported as zero, so
  459. a missing binding never manufactures a shortfall.
  460. This is the pool the AMS-Filament-Backup accounting draws on (#1762), and
  461. the same data the PrintModal pre-flight check consumes through
  462. ``GET /printers/{id}/inventory-remain``. Both sides share it so the modal's
  463. warning and the dispatcher's 409 can't disagree about what backs what up.
  464. """
  465. _, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
  466. # ``live_keys``: a caller that has keyed its own slots passes the same read,
  467. # so a status push in between cannot put them in different pools.
  468. if live_keys is None:
  469. live_keys = _live_tray_keys(printer_id)
  470. materials: list[SlotMaterial] = []
  471. def _append(
  472. ams_id: int,
  473. tray_id: int,
  474. material_key: str,
  475. remaining: float,
  476. spool: SlotSpoolIdentity | None = None,
  477. cost_per_kg: float | None = None,
  478. ) -> None:
  479. global_tray_id = _ams_key_to_global(ams_id, tray_id)
  480. materials.append(
  481. SlotMaterial(
  482. ams_id=ams_id,
  483. tray_id=tray_id,
  484. global_tray_id=global_tray_id,
  485. material_key=live_keys.get(global_tray_id, material_key),
  486. remaining_grams=remaining,
  487. extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
  488. spool=spool,
  489. cost_per_kg=cost_per_kg,
  490. )
  491. )
  492. if await _is_spoolman_mode(db):
  493. from backend.app.services.spoolman_tracking import _spool_cost_per_gram
  494. sm_all = await db.execute(select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
  495. from backend.app.services.spoolman import (
  496. SpoolmanClientError,
  497. SpoolmanNotFoundError,
  498. get_spoolman_client,
  499. )
  500. try:
  501. client = await get_spoolman_client()
  502. except Exception:
  503. client = None
  504. if client is None:
  505. return []
  506. for sa in sm_all.scalars().all():
  507. try:
  508. spool_dict = await client.get_spool(sa.spoolman_spool_id)
  509. except (SpoolmanNotFoundError, SpoolmanClientError):
  510. continue
  511. except Exception as e:
  512. logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
  513. continue
  514. if not spool_dict:
  515. continue
  516. remaining: float | None = None
  517. rw = spool_dict.get("remaining_weight")
  518. if isinstance(rw, (int, float)) and rw >= 0:
  519. remaining = float(rw)
  520. else:
  521. used = spool_dict.get("used_weight")
  522. total = spoolman_net_weight(spool_dict)
  523. if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
  524. remaining = max(0.0, float(total) - float(used))
  525. if remaining is None:
  526. continue
  527. per_gram = _spool_cost_per_gram(spool_dict)
  528. _append(
  529. sa.ams_id,
  530. sa.tray_id,
  531. _material_identity_spoolman(spool_dict),
  532. remaining,
  533. _identity_from_spoolman(spool_dict),
  534. per_gram * 1000.0 if per_gram is not None else None,
  535. )
  536. return materials
  537. internal_all = await db.execute(
  538. select(SpoolAssignment)
  539. .options(selectinload(SpoolAssignment.spool))
  540. .where(SpoolAssignment.printer_id == printer_id)
  541. )
  542. for assignment in internal_all.scalars().all():
  543. spool = assignment.spool
  544. if spool is None:
  545. continue
  546. label_weight = float(spool.label_weight or 0)
  547. weight_used = float(spool.weight_used or 0)
  548. if label_weight <= 0:
  549. continue
  550. _append(
  551. assignment.ams_id,
  552. assignment.tray_id,
  553. _material_identity_internal(spool),
  554. max(0.0, label_weight - weight_used),
  555. _identity_from_internal(spool),
  556. spool.cost_per_kg,
  557. )
  558. return materials
  559. async def compute_deficit_for_queue_item(
  560. db: AsyncSession,
  561. item: PrintQueueItem,
  562. *,
  563. printer_id: int | None = None,
  564. ams_mapping: list[int] | None = None,
  565. require_known: bool = False,
  566. ) -> list[FilamentDeficit]:
  567. """Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
  568. ``printer_id`` and ``ams_mapping`` ask about a printer the item is not
  569. assigned to yet, with the mapping it would get there: the model-based
  570. matcher uses them to pass over a printer that would be short (#3137)
  571. before it commits the item to one. Without them, the item's own columns
  572. are read.
  573. ``require_known`` also reports every printed slot whose remaining amount
  574. cannot be determined (no tray mapped, no spool assigned, no label weight,
  575. Spoolman not answering), with ``remaining_grams=None``. Everywhere else an
  576. unknown amount lets the print through; a caller about to *start* a held
  577. job on its own uses this so it only does so on a positive finding.
  578. Returns an empty list whenever any of the following hold:
  579. * The ``disable_filament_warnings`` setting is on.
  580. * The item has no resolved ``printer_id`` (model-based assignment not
  581. yet picked a printer — the scheduler re-runs the check after it does).
  582. * No source 3MF is available, or the 3MF carries no per-slot
  583. requirements (treated as "nothing to verify" rather than an error,
  584. matching the PrintModal behaviour).
  585. * No AMS mapping is set yet — the scheduler computes the mapping just
  586. before dispatch; until it does we cannot map slot → tray.
  587. * Spoolman mode is on but the Spoolman server is unreachable. We do not
  588. wedge the queue on a network blip.
  589. #1762: when the printer reports ``ams_filament_backup=True`` in MQTT
  590. status, available material is pooled across ALL same-material spools on
  591. the printer (within the same extruder side for dual-nozzle models, since
  592. firmware can't cross extruders even with the backup bit set). Per-slot
  593. shortfalls are then only emitted if the POOL is too small for the
  594. print's total required of that material — matching how the printer
  595. actually behaves with Filament Backup ON.
  596. """
  597. if await _warnings_disabled(db):
  598. return []
  599. if printer_id is None:
  600. printer_id = item.printer_id
  601. if printer_id is None:
  602. return []
  603. # Refresh the relationships we need without assuming the caller eagerly
  604. # loaded them — both the route and the scheduler call this from contexts
  605. # with different loading strategies.
  606. refreshed = await db.execute(
  607. select(PrintQueueItem)
  608. .options(
  609. selectinload(PrintQueueItem.archive),
  610. selectinload(PrintQueueItem.library_file),
  611. )
  612. .where(PrintQueueItem.id == item.id)
  613. )
  614. item = refreshed.scalar_one_or_none() or item
  615. source_path = _resolve_source_3mf(item)
  616. if source_path is None:
  617. # No archive and no library file — nothing was ever attached to check.
  618. return []
  619. if not source_path.exists():
  620. # Dispatch is not blocked: the upload that follows needs the same file
  621. # and fails within seconds, where wedging the queue here would strand
  622. # it. But skipping a safety check must leave a trace — a silent skip is
  623. # what hid #2779 for every library-backed item.
  624. logger.warning(
  625. "Filament check skipped for queue item %s: source 3MF not found at %s",
  626. item.id,
  627. source_path,
  628. )
  629. return []
  630. requirements = extract_filament_requirements(source_path, item.plate_id)
  631. if not requirements:
  632. return []
  633. mapping = ams_mapping if ams_mapping is not None else _parse_ams_mapping(item.ams_mapping)
  634. if not mapping:
  635. return []
  636. spoolman_mode = await _is_spoolman_mode(db)
  637. backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
  638. live_keys = _live_tray_keys(printer_id) if backup_on else {}
  639. # ------------------------------------------------------------------ phase 1
  640. # Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
  641. # Slot identity is what the printer reports for the tray, else the spool
  642. # *assigned to that slot* (see ``_live_tray_keys``). A
  643. # ``None`` remaining means "couldn't determine" — treated as "no deficit"
  644. # below (preserved from pre-#1762 behaviour for non-backup paths too).
  645. @dataclass
  646. class _ReqRow:
  647. slot_id: int
  648. ams_id: int
  649. tray_id: int
  650. global_tray_id: int
  651. required: float
  652. identity: str
  653. remaining: float | None
  654. filament_type: str
  655. extruder: int
  656. resolved: list[_ReqRow] = []
  657. # Printed slots whose remaining amount is unknown; only returned with
  658. # ``require_known``.
  659. undetermined: list[FilamentDeficit] = []
  660. def _undetermined(req: dict, ams_id: int | None = None, tray_id: int | None = None) -> None:
  661. undetermined.append(
  662. FilamentDeficit(
  663. slot_id=req["slot_id"],
  664. ams_id=ams_id,
  665. tray_id=tray_id,
  666. filament_type=str(req.get("type", "")),
  667. required_grams=float(req["used_grams"]),
  668. remaining_grams=None,
  669. )
  670. )
  671. for req in requirements:
  672. slot_id = req.get("slot_id")
  673. used_grams = req.get("used_grams")
  674. if not isinstance(slot_id, int) or slot_id <= 0:
  675. continue
  676. if not isinstance(used_grams, (int, float)) or used_grams <= 0:
  677. continue
  678. idx = slot_id - 1
  679. if idx >= len(mapping):
  680. _undetermined(req)
  681. continue
  682. global_tray_id = mapping[idx]
  683. if global_tray_id is None or global_tray_id < 0:
  684. _undetermined(req)
  685. continue
  686. ams_id, tray_id = _global_to_ams_key(global_tray_id)
  687. identity = "attrs:|||"
  688. remaining: float | None = None
  689. if spoolman_mode:
  690. sm_result = await db.execute(
  691. select(SpoolmanSlotAssignment).where(
  692. SpoolmanSlotAssignment.printer_id == printer_id,
  693. SpoolmanSlotAssignment.ams_id == ams_id,
  694. SpoolmanSlotAssignment.tray_id == tray_id,
  695. )
  696. )
  697. sm_assignment = sm_result.scalar_one_or_none()
  698. if sm_assignment is None:
  699. _undetermined(req, ams_id, tray_id)
  700. continue
  701. # Live remaining_weight from Spoolman. The fetch also resolves the
  702. # filament identity for pooling (material + colour + name).
  703. from backend.app.services.spoolman import (
  704. SpoolmanClientError,
  705. SpoolmanNotFoundError,
  706. get_spoolman_client,
  707. )
  708. try:
  709. client = await get_spoolman_client()
  710. spool_dict = await client.get_spool(sm_assignment.spoolman_spool_id) if client else None
  711. except (SpoolmanNotFoundError, SpoolmanClientError):
  712. spool_dict = None
  713. except Exception as e:
  714. logger.debug("Spoolman fetch failed for spool %s: %s", sm_assignment.spoolman_spool_id, e)
  715. spool_dict = None
  716. if spool_dict:
  717. identity = _material_identity_spoolman(spool_dict)
  718. rw = spool_dict.get("remaining_weight")
  719. if isinstance(rw, (int, float)) and rw >= 0:
  720. remaining = float(rw)
  721. else:
  722. used = spool_dict.get("used_weight")
  723. total = spoolman_net_weight(spool_dict)
  724. if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
  725. remaining = max(0.0, float(total) - float(used))
  726. else:
  727. internal_result = await db.execute(
  728. select(SpoolAssignment)
  729. .options(selectinload(SpoolAssignment.spool))
  730. .where(
  731. SpoolAssignment.printer_id == printer_id,
  732. SpoolAssignment.ams_id == ams_id,
  733. SpoolAssignment.tray_id == tray_id,
  734. )
  735. )
  736. assignment = internal_result.scalar_one_or_none()
  737. if assignment is None or assignment.spool is None:
  738. _undetermined(req, ams_id, tray_id)
  739. continue
  740. spool = assignment.spool
  741. identity = _material_identity_internal(spool)
  742. label_weight = float(spool.label_weight or 0)
  743. weight_used = float(spool.weight_used or 0)
  744. if label_weight <= 0:
  745. _undetermined(req, ams_id, tray_id)
  746. continue
  747. remaining = max(0.0, label_weight - weight_used)
  748. if remaining is None:
  749. # Unable to determine remaining grams — preserve pre-#1762 behaviour
  750. # (don't block on undetermined data).
  751. _undetermined(req, ams_id, tray_id)
  752. continue
  753. resolved.append(
  754. _ReqRow(
  755. slot_id=slot_id,
  756. ams_id=ams_id,
  757. tray_id=tray_id,
  758. global_tray_id=global_tray_id,
  759. required=float(used_grams),
  760. # Must key the same way as ``build_slot_materials``, or the
  761. # slot's demand lands in a pool its own grams are not in.
  762. identity=live_keys.get(global_tray_id, identity),
  763. remaining=remaining,
  764. filament_type=str(req.get("type", "")),
  765. extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
  766. )
  767. )
  768. # ------------------------------------------------------------------ phase 2
  769. # When backup is OFF, fall back to today's per-slot accounting (one-line
  770. # equivalence of the original loop), so this path is a strict no-op
  771. # behaviour-wise vs. the pre-#1762 code.
  772. unknown = undetermined if require_known else []
  773. if not backup_on:
  774. return unknown + [
  775. FilamentDeficit(
  776. slot_id=row.slot_id,
  777. ams_id=row.ams_id,
  778. tray_id=row.tray_id,
  779. filament_type=row.filament_type,
  780. required_grams=row.required,
  781. remaining_grams=row.remaining,
  782. )
  783. for row in resolved
  784. if row.remaining is not None and row.remaining < row.required
  785. ]
  786. # ------------------------------------------------------------------ phase 3
  787. # Backup ON: build (identity, extruder)-keyed pool and required-sum maps
  788. # from EVERY assigned spool on the printer (not just the slots in the
  789. # print's mapping). Then emit deficits only when the pool for a slot's
  790. # material is too small for the print's total required of that material.
  791. pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
  792. required_by_key: dict[tuple[str, int], float] = defaultdict(float)
  793. for slot in await build_slot_materials(db, printer_id, live_keys=live_keys):
  794. pool_by_key[(slot.material_key, slot.extruder)] += slot.remaining_grams
  795. for row in resolved:
  796. required_by_key[(row.identity, row.extruder)] += row.required
  797. deficits: list[FilamentDeficit] = []
  798. for row in resolved:
  799. key = (row.identity, row.extruder)
  800. # Pool insufficient for the print's TOTAL required of this material on
  801. # this extruder side → real deficit. The per-slot remaining still gets
  802. # surfaced so the UI can point at the slot the user assigned.
  803. if pool_by_key[key] < required_by_key[key]:
  804. deficits.append(
  805. FilamentDeficit(
  806. slot_id=row.slot_id,
  807. ams_id=row.ams_id,
  808. tray_id=row.tray_id,
  809. filament_type=row.filament_type,
  810. required_grams=row.required,
  811. remaining_grams=row.remaining,
  812. )
  813. )
  814. return unknown + deficits
  815. # Re-export the most useful pieces for callers that just want the data.
  816. __all__ = [
  817. "FilamentDeficit",
  818. "compute_deficit_for_queue_item",
  819. ]