slicer_filament_resolver.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. """Shared spool ``slicer_filament`` → ``(tray_info_idx, setting_id)`` resolver.
  2. The internal-inventory and Spoolman-inventory routes both need to translate
  3. a spool's stored slicer-preset reference (cloud preset ID / local preset ID /
  4. GF-prefix Bambu filament ID / free-text material name) into the two MQTT
  5. fields ``ams_filament_setting`` consumes: the printer-side ``tray_info_idx``
  6. (filament_id) and the slicer-side ``setting_id``. The two routes were drifting
  7. in lockstep before #1713 — internal mode resolved everything, Spoolman mode
  8. silently dropped slicer_filament on the floor and only the generic-material
  9. fallback fired. This module is the single chokepoint so the two flows can't
  10. diverge again.
  11. Resolver outcomes:
  12. - Returns ``("", "", None)`` when ``slicer_filament`` is empty, unresolvable,
  13. or sanitised away as a slicer-rejected value (literal material name,
  14. PFUS / PFCN cloud setting_id). The caller is responsible for the
  15. generic-material fallback when this happens.
  16. - Returns ``(tray_info_idx, setting_id, sub_brand_override)`` otherwise.
  17. The third element is non-empty when a cloud-detail lookup or a local-
  18. preset name provides a more specific brand label than the spool's own
  19. ``"<brand> <material> <subtype>"`` concatenation — the caller should
  20. prefer it over its computed default.
  21. The resolver is async because the GFS / PFUS / PFCN branches need cloud
  22. authentication and the local-preset branch reads ``LocalPreset`` from the
  23. DB. Pass ``current_user=None`` to skip cloud auth (the on_ams_change
  24. replay path uses this); cloud-prefix presets then fall back to a static
  25. ``normalize_slicer_filament`` parse, which is correct when the slot was
  26. already configured by an earlier authenticated assign and the printer's
  27. calibration table preserves the real filament_id.
  28. """
  29. from __future__ import annotations
  30. import json
  31. import logging
  32. import re
  33. from sqlalchemy import select
  34. from sqlalchemy.ext.asyncio import AsyncSession
  35. from backend.app.core.permissions import Permission
  36. from backend.app.models.user import User
  37. from backend.app.utils.filament_ids import (
  38. GENERIC_FILAMENT_IDS,
  39. filament_id_to_setting_id,
  40. normalize_slicer_filament,
  41. )
  42. from backend.app.utils.filament_types import is_material_name
  43. logger = logging.getLogger(__name__)
  44. # Orca Cloud profile ids are UUIDs, the one preset reference in this codebase
  45. # with no letter prefix to key off. A spool stores the bare id (the spool form
  46. # persists ``preset.setting_id`` verbatim), so shape is all there is to go on.
  47. _ORCA_PROFILE_ID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
  48. async def _orca_filament_id(
  49. db: AsyncSession,
  50. current_user: User | None,
  51. profile_id: str,
  52. ) -> tuple[str, str | None, str | None]:
  53. """Look up an Orca Cloud profile's own filament_id.
  54. Returns ``(filament_id, name, filament_type)`` -- all empty/None when the
  55. profile cannot be fetched or carries no id of its own, which leaves the
  56. caller on its generic fallback.
  57. Best-effort by construction: this runs inside spool assignment, not a user
  58. request, so a missing pairing, a revoked token or a lapsed permission must
  59. degrade to the fallback rather than fail the assignment. That is also why
  60. ``clear_on_auth_failure=False`` -- Orca reports every refresh rejection with
  61. one composite reason, so a background caller cannot tell a real revocation
  62. from a lost rotation race and must not wipe a working pairing on it. The
  63. route path hits the same failure in front of a user and clears there.
  64. """
  65. if current_user is not None and not current_user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
  66. logger.debug("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
  67. return ("", None, None)
  68. svc = None
  69. try:
  70. from backend.app.api.routes.orca_cloud import _build_authenticated_service
  71. svc = await _build_authenticated_service(db, current_user, clear_on_auth_failure=False)
  72. profile = await svc.get_profile(profile_id)
  73. except Exception as e:
  74. logger.debug("Orca filament lookup failed for %r: %s", profile_id, e)
  75. return ("", None, None)
  76. finally:
  77. # A raise in `finally` escapes the `except` above, so guard it: closing
  78. # an httpx client must never be what fails a spool assignment.
  79. if svc is not None:
  80. try:
  81. await svc.close()
  82. except Exception as e: # noqa: BLE001 - close() is best-effort
  83. logger.debug("Orca client close failed after lookup of %r: %s", profile_id, e)
  84. content = profile.get("content") if isinstance(profile, dict) else None
  85. if not isinstance(content, dict):
  86. return ("", None, None)
  87. raw_fid = content.get("filament_id")
  88. filament_id = raw_fid.strip() if isinstance(raw_fid, str) else ""
  89. name = profile.get("name") if isinstance(profile, dict) else None
  90. return (
  91. filament_id,
  92. name if isinstance(name, str) and name else None,
  93. _preset_filament_type(content.get("filament_type")),
  94. )
  95. def _preset_filament_type(raw: object) -> str | None:
  96. """Read a slicer preset's ``filament_type`` field.
  97. Bambu Studio and OrcaSlicer both store it as a one-element array
  98. (``["PLA"]``); some hand-written and older profiles store a bare string.
  99. ``orca_profiles._extract_filament_fields`` accepts both and this has to
  100. agree with it, since that is what fills ``LocalPreset.filament_type``.
  101. The value is still run through ``printer_filament_type`` by the caller.
  102. That is a no-op for every type the app knows -- ``TestTheMaterialsBambuddyOffers``
  103. pins exactly that -- and a pass-through for a type it does not, so nothing
  104. the slicer says is discarded. It only bites on a hand-edited profile whose
  105. ``filament_type`` is a product line, which is the case this whole module
  106. exists to keep out of an AMS slot.
  107. """
  108. if isinstance(raw, list):
  109. raw = raw[0] if raw else None
  110. if isinstance(raw, str) and raw.strip():
  111. return raw.strip()
  112. return None
  113. async def resolve_slicer_filament(
  114. *,
  115. db: AsyncSession,
  116. current_user: User | None,
  117. slicer_filament: str | None,
  118. slicer_filament_name: str | None,
  119. material: str | None,
  120. ) -> tuple[str, str, str | None, str | None]:
  121. """Resolve a spool's slicer-preset reference to printer-side ids.
  122. ``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
  123. ``"PFUS990b6e19965353"``, ``"38"`` for a numeric LocalPreset id, or
  124. free-text). May be empty or None — returns the empty tuple in that case.
  125. ``slicer_filament_name``: optional builtin-name realignment hint. When
  126. set and the resolved tray_info_idx maps to a different builtin name,
  127. the resolver swaps to the builtin whose name matches (e.g. user picked
  128. "Bambu PLA Matte" but the cloud lookup landed on "Bambu PLA Basic").
  129. ``material``: spool material string for the local-preset fallback
  130. branch when the LocalPreset's setting JSON doesn't carry a filament_id.
  131. Returns ``(tray_info_idx, setting_id, sub_brand_override, type_override)``
  132. — all empty when nothing resolved. ``sub_brand_override`` is non-None when
  133. a more specific brand label is available (cloud detail name or local preset
  134. name); ``None`` means the caller should use its own default.
  135. ``type_override`` is the preset's own ``filament_type`` when the preset
  136. carries one — the slicer's answer to what the material is, rather than one
  137. parsed out of the spool's material column. It is what the caller should
  138. write into ``tray_type``. ``None`` means no preset said, and the caller
  139. falls back to reducing the spool's material (``printer_filament_type``).
  140. Raised in the #2902 thread by @doncaruana: a preset has to be chosen from
  141. a list the slicer defines, so its type needs no interpreting. It cannot be
  142. the only source, though — ``slicer_filament`` is nullable on a spool while
  143. ``material`` is required, and the spool this issue was reported for had no
  144. preset at all.
  145. """
  146. sf = (slicer_filament or "").strip()
  147. if not sf:
  148. return ("", "", None, None)
  149. tray_info_idx = ""
  150. setting_id = ""
  151. sub_brand_override: str | None = None
  152. type_override: str | None = None
  153. base_sf = sf.split("_")[0] if "_" in sf else sf
  154. # Cloud-side preset IDs in three known shapes:
  155. # GFS… — Bambu official cloud preset
  156. # PFUS… — cloud user-created preset
  157. # PFCN… — cloud shared / partner preset (e.g. Polymaker's "(Custom)"
  158. # Bambu Lab H2D variant, #1648)
  159. # All three need a cloud-detail lookup to extract the underlying
  160. # filament_id; without it the raw cloud id ends up in tray_info_idx
  161. # and the printer's calibration table can't resolve it.
  162. # Source order is Orca Cloud, Bambu Cloud, local import, generic fallback.
  163. # Orca goes first because its ids are the only ones identified by shape
  164. # rather than prefix -- and because, before #3003, a UUID fell through every
  165. # branch below into ``normalize_slicer_filament``, which passes anything it
  166. # does not recognise straight through. A 36-character UUID then went into
  167. # tray_info_idx, an 8-character field, and the slot ended up pointing at the
  168. # first 8 characters of a UUID: the same failure the PFUS guard at the
  169. # bottom of this function exists for.
  170. if _ORCA_PROFILE_ID.fullmatch(base_sf):
  171. tray_info_idx, orca_name, orca_type = await _orca_filament_id(db, current_user, base_sf)
  172. if orca_type:
  173. type_override = orca_type
  174. if orca_name:
  175. sub_brand_override = orca_name.split("@")[0].strip()
  176. # setting_id is left empty here: the UUID is what the slicer cannot
  177. # resolve, and unlike a PFUS there is no cloud id form it accepts
  178. # instead. All three callers then derive one from the filament_id
  179. # (`filament_id_to_setting_id`), which is what keeps the slot from
  180. # going out half configured -- the same path a local import takes.
  181. elif base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
  182. setting_id = base_sf
  183. try:
  184. from backend.app.api.routes.cloud import build_authenticated_cloud
  185. cloud = await build_authenticated_cloud(db, current_user)
  186. if cloud is not None and cloud.is_authenticated:
  187. try:
  188. detail = await cloud.get_setting_detail(base_sf)
  189. # The preset's own type, straight from the slicer's own
  190. # profile -- no parsing of a product name (#2902). The
  191. # preset JSON is nested under ``setting``; some responses
  192. # carry it at the top level instead, the same shape spread
  193. # ``preset_resolver`` documents.
  194. cloud_setting = detail.get("setting")
  195. type_override = _preset_filament_type(
  196. (cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
  197. )
  198. # A custom preset's OWN filament_id is the only thing that
  199. # gets it into an AMS slot as itself: the printer stores
  200. # that id, the slicer matches its presets against it, and
  201. # the 8-character field fits it exactly ("P" + 7 hex).
  202. # Bambu Cloud puts it in either of two places -- on the
  203. # envelope, or inside the preset JSON under `setting` --
  204. # the same spread `filament_type` above already handles.
  205. # Reading only the envelope is how a custom preset fell
  206. # through to the base_id branch below and reached the
  207. # slicer as the Bambu profile it inherits from (#3003).
  208. own_filament_id = detail.get("filament_id") or (
  209. cloud_setting.get("filament_id") if isinstance(cloud_setting, dict) else None
  210. )
  211. if own_filament_id:
  212. tray_info_idx = own_filament_id
  213. cloud_name = detail.get("name", "")
  214. if cloud_name:
  215. sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
  216. elif detail.get("base_id"):
  217. bid = detail["base_id"].split("_")[0]
  218. if bid.startswith("GFS") and len(bid) >= 5:
  219. tray_info_idx = f"GF{bid[3:]}"
  220. else:
  221. tray_info_idx = bid
  222. finally:
  223. await cloud.close()
  224. elif cloud is not None:
  225. await cloud.close()
  226. except Exception as e:
  227. logger.warning("Slicer-filament resolve: cloud lookup failed for %r: %s", sf, e)
  228. if not tray_info_idx:
  229. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  230. elif base_sf.startswith("GF"):
  231. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  232. else:
  233. try:
  234. local_id = int(sf)
  235. from backend.app.models.local_preset import LocalPreset as LP
  236. lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
  237. lp = lp_result.scalar_one_or_none()
  238. if lp:
  239. # The slicer's own answer, extracted from the profile at import
  240. # time by ``orca_profiles``. Preferred over anything parsed out
  241. # of the spool's material column (#2902).
  242. type_override = _preset_filament_type(lp.filament_type)
  243. # Local preset's setting JSON carries the printer-recognized
  244. # filament_id (e.g. "P4d64437") — use that directly so the
  245. # slicer can resolve the specific preset. Falls through to
  246. # generic material id only when the JSON doesn't carry one.
  247. lp_filament_id = ""
  248. if lp.setting:
  249. try:
  250. setting_data = json.loads(lp.setting)
  251. raw_fid = setting_data.get("filament_id")
  252. if isinstance(raw_fid, str) and raw_fid:
  253. lp_filament_id = raw_fid
  254. except (json.JSONDecodeError, AttributeError):
  255. pass
  256. if lp_filament_id:
  257. tray_info_idx = lp_filament_id
  258. setting_id = filament_id_to_setting_id(lp_filament_id)
  259. else:
  260. # Deliberately not widened to cover product-line materials
  261. # ("PLA+", "HTPLA") the way the callers' own fallbacks were
  262. # (#2902). Returning an id here rather than "" would skip
  263. # the caller's whole no-id block, and with it the slot-reuse
  264. # branch that keeps a printer's calibrated preset -- so the
  265. # widening belongs there, after reuse has had its turn.
  266. mat = (material or lp.filament_type or "").upper().strip()
  267. tray_info_idx = (
  268. GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
  269. )
  270. if lp.name:
  271. sub_brand_override = lp.name.split("@")[0].strip()
  272. except (ValueError, TypeError):
  273. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  274. # Realign tray_info_idx to a builtin whose name matches slicer_filament_name
  275. # when the current resolution lands on a builtin with a different name
  276. # (e.g. cloud detail returned PLA Basic but the spool was labelled PLA Matte).
  277. if tray_info_idx and slicer_filament_name:
  278. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  279. expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
  280. if expected_name and expected_name != slicer_filament_name:
  281. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  282. if fname == slicer_filament_name:
  283. tray_info_idx = fid
  284. setting_id = filament_id_to_setting_id(fid)
  285. break
  286. # Defend against tray_info_idx values the slicer cannot resolve. Three
  287. # shapes leak through and must be discarded so the caller's generic-
  288. # material fallback can rescue the slot:
  289. # 1. Literal material names ("PLA", "PETG-CF") that pass through
  290. # normalize_slicer_filament unchanged when the spool's slicer_filament
  291. # is free-text rather than a real preset ID. Product lines ("PLA+",
  292. # "HTPLA") count as material names too -- see is_material_name, which
  293. # is shared with the slot-reuse check that must agree with this.
  294. # 2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
  295. # by the slicer as tray_info_idx (the printer's calibration table
  296. # indexes by filament_id, and a PFUS isn't one). This normally gets
  297. # realigned to a P-prefix local id via the caller's printer_kp
  298. # lookup, but on the replay path in main.py.on_ams_change
  299. # current_user=None skips cloud auth and leaves the raw PFUS in
  300. # tray_info_idx — overwriting the correctly-configured slot from
  301. # the original assign.
  302. # 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
  303. # "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
  304. # 4. Orca Cloud profile UUIDs, when the branch above could not reach the
  305. # profile to trade one for its filament_id (#3003). Worst of the four
  306. # at 36 characters against an 8-character field.
  307. # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
  308. # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
  309. if tray_info_idx and (
  310. is_material_name(tray_info_idx)
  311. or tray_info_idx.startswith("PFUS")
  312. or tray_info_idx.startswith("PFCN")
  313. or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
  314. ):
  315. tray_info_idx = ""
  316. # Preserve setting_id when it's still a valid slicer reference
  317. # (PFUS / PFCN cloud user/shared preset, or GFS Bambu official
  318. # preset). The slicer accepts these as setting_id even though
  319. # they're rejected as tray_info_idx; without preservation the
  320. # slicer falls back to whatever generic filament the caller's
  321. # tray_info_idx fallback produces and shows "Generic <Material>"
  322. # instead of the user's actual custom preset (#1815). Material-name
  323. # leaks (e.g. setting_id="PETG") are still cleared — those are
  324. # never valid slicer references.
  325. if not (
  326. setting_id
  327. and (setting_id.startswith("PFUS") or setting_id.startswith("PFCN") or setting_id.startswith("GFS"))
  328. ):
  329. setting_id = ""
  330. return (tray_info_idx, setting_id, sub_brand_override, type_override)