slicer_filament_resolver.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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. from sqlalchemy import select
  33. from sqlalchemy.ext.asyncio import AsyncSession
  34. from backend.app.models.user import User
  35. from backend.app.utils.filament_ids import (
  36. GENERIC_FILAMENT_IDS,
  37. filament_id_to_setting_id,
  38. normalize_slicer_filament,
  39. )
  40. from backend.app.utils.filament_types import is_material_name
  41. logger = logging.getLogger(__name__)
  42. def _preset_filament_type(raw: object) -> str | None:
  43. """Read a slicer preset's ``filament_type`` field.
  44. Bambu Studio and OrcaSlicer both store it as a one-element array
  45. (``["PLA"]``); some hand-written and older profiles store a bare string.
  46. ``orca_profiles._extract_filament_fields`` accepts both and this has to
  47. agree with it, since that is what fills ``LocalPreset.filament_type``.
  48. The value is still run through ``printer_filament_type`` by the caller.
  49. That is a no-op for every type the app knows -- ``TestTheMaterialsBambuddyOffers``
  50. pins exactly that -- and a pass-through for a type it does not, so nothing
  51. the slicer says is discarded. It only bites on a hand-edited profile whose
  52. ``filament_type`` is a product line, which is the case this whole module
  53. exists to keep out of an AMS slot.
  54. """
  55. if isinstance(raw, list):
  56. raw = raw[0] if raw else None
  57. if isinstance(raw, str) and raw.strip():
  58. return raw.strip()
  59. return None
  60. async def resolve_slicer_filament(
  61. *,
  62. db: AsyncSession,
  63. current_user: User | None,
  64. slicer_filament: str | None,
  65. slicer_filament_name: str | None,
  66. material: str | None,
  67. ) -> tuple[str, str, str | None, str | None]:
  68. """Resolve a spool's slicer-preset reference to printer-side ids.
  69. ``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
  70. ``"PFUS990b6e19965353"``, ``"38"`` for a numeric LocalPreset id, or
  71. free-text). May be empty or None — returns the empty tuple in that case.
  72. ``slicer_filament_name``: optional builtin-name realignment hint. When
  73. set and the resolved tray_info_idx maps to a different builtin name,
  74. the resolver swaps to the builtin whose name matches (e.g. user picked
  75. "Bambu PLA Matte" but the cloud lookup landed on "Bambu PLA Basic").
  76. ``material``: spool material string for the local-preset fallback
  77. branch when the LocalPreset's setting JSON doesn't carry a filament_id.
  78. Returns ``(tray_info_idx, setting_id, sub_brand_override, type_override)``
  79. — all empty when nothing resolved. ``sub_brand_override`` is non-None when
  80. a more specific brand label is available (cloud detail name or local preset
  81. name); ``None`` means the caller should use its own default.
  82. ``type_override`` is the preset's own ``filament_type`` when the preset
  83. carries one — the slicer's answer to what the material is, rather than one
  84. parsed out of the spool's material column. It is what the caller should
  85. write into ``tray_type``. ``None`` means no preset said, and the caller
  86. falls back to reducing the spool's material (``printer_filament_type``).
  87. Raised in the #2902 thread by @doncaruana: a preset has to be chosen from
  88. a list the slicer defines, so its type needs no interpreting. It cannot be
  89. the only source, though — ``slicer_filament`` is nullable on a spool while
  90. ``material`` is required, and the spool this issue was reported for had no
  91. preset at all.
  92. """
  93. sf = (slicer_filament or "").strip()
  94. if not sf:
  95. return ("", "", None, None)
  96. tray_info_idx = ""
  97. setting_id = ""
  98. sub_brand_override: str | None = None
  99. type_override: str | None = None
  100. base_sf = sf.split("_")[0] if "_" in sf else sf
  101. # Cloud-side preset IDs in three known shapes:
  102. # GFS… — Bambu official cloud preset
  103. # PFUS… — cloud user-created preset
  104. # PFCN… — cloud shared / partner preset (e.g. Polymaker's "(Custom)"
  105. # Bambu Lab H2D variant, #1648)
  106. # All three need a cloud-detail lookup to extract the underlying
  107. # filament_id; without it the raw cloud id ends up in tray_info_idx
  108. # and the printer's calibration table can't resolve it.
  109. if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
  110. setting_id = base_sf
  111. try:
  112. from backend.app.api.routes.cloud import build_authenticated_cloud
  113. cloud = await build_authenticated_cloud(db, current_user)
  114. if cloud is not None and cloud.is_authenticated:
  115. try:
  116. detail = await cloud.get_setting_detail(base_sf)
  117. # The preset's own type, straight from the slicer's own
  118. # profile -- no parsing of a product name (#2902). The
  119. # preset JSON is nested under ``setting``; some responses
  120. # carry it at the top level instead, the same shape spread
  121. # ``preset_resolver`` documents.
  122. cloud_setting = detail.get("setting")
  123. type_override = _preset_filament_type(
  124. (cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
  125. )
  126. if detail.get("filament_id"):
  127. tray_info_idx = detail["filament_id"]
  128. cloud_name = detail.get("name", "")
  129. if cloud_name:
  130. sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
  131. elif detail.get("base_id"):
  132. bid = detail["base_id"].split("_")[0]
  133. if bid.startswith("GFS") and len(bid) >= 5:
  134. tray_info_idx = f"GF{bid[3:]}"
  135. else:
  136. tray_info_idx = bid
  137. finally:
  138. await cloud.close()
  139. elif cloud is not None:
  140. await cloud.close()
  141. except Exception as e:
  142. logger.warning("Slicer-filament resolve: cloud lookup failed for %r: %s", sf, e)
  143. if not tray_info_idx:
  144. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  145. elif base_sf.startswith("GF"):
  146. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  147. else:
  148. try:
  149. local_id = int(sf)
  150. from backend.app.models.local_preset import LocalPreset as LP
  151. lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
  152. lp = lp_result.scalar_one_or_none()
  153. if lp:
  154. # The slicer's own answer, extracted from the profile at import
  155. # time by ``orca_profiles``. Preferred over anything parsed out
  156. # of the spool's material column (#2902).
  157. type_override = _preset_filament_type(lp.filament_type)
  158. # Local preset's setting JSON carries the printer-recognized
  159. # filament_id (e.g. "P4d64437") — use that directly so the
  160. # slicer can resolve the specific preset. Falls through to
  161. # generic material id only when the JSON doesn't carry one.
  162. lp_filament_id = ""
  163. if lp.setting:
  164. try:
  165. setting_data = json.loads(lp.setting)
  166. raw_fid = setting_data.get("filament_id")
  167. if isinstance(raw_fid, str) and raw_fid:
  168. lp_filament_id = raw_fid
  169. except (json.JSONDecodeError, AttributeError):
  170. pass
  171. if lp_filament_id:
  172. tray_info_idx = lp_filament_id
  173. setting_id = filament_id_to_setting_id(lp_filament_id)
  174. else:
  175. # Deliberately not widened to cover product-line materials
  176. # ("PLA+", "HTPLA") the way the callers' own fallbacks were
  177. # (#2902). Returning an id here rather than "" would skip
  178. # the caller's whole no-id block, and with it the slot-reuse
  179. # branch that keeps a printer's calibrated preset -- so the
  180. # widening belongs there, after reuse has had its turn.
  181. mat = (material or lp.filament_type or "").upper().strip()
  182. tray_info_idx = (
  183. GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
  184. )
  185. if lp.name:
  186. sub_brand_override = lp.name.split("@")[0].strip()
  187. except (ValueError, TypeError):
  188. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  189. # Realign tray_info_idx to a builtin whose name matches slicer_filament_name
  190. # when the current resolution lands on a builtin with a different name
  191. # (e.g. cloud detail returned PLA Basic but the spool was labelled PLA Matte).
  192. if tray_info_idx and slicer_filament_name:
  193. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  194. expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
  195. if expected_name and expected_name != slicer_filament_name:
  196. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  197. if fname == slicer_filament_name:
  198. tray_info_idx = fid
  199. setting_id = filament_id_to_setting_id(fid)
  200. break
  201. # Defend against tray_info_idx values the slicer cannot resolve. Three
  202. # shapes leak through and must be discarded so the caller's generic-
  203. # material fallback can rescue the slot:
  204. # 1. Literal material names ("PLA", "PETG-CF") that pass through
  205. # normalize_slicer_filament unchanged when the spool's slicer_filament
  206. # is free-text rather than a real preset ID. Product lines ("PLA+",
  207. # "HTPLA") count as material names too -- see is_material_name, which
  208. # is shared with the slot-reuse check that must agree with this.
  209. # 2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
  210. # by the slicer as tray_info_idx (the printer's calibration table
  211. # indexes by filament_id, and a PFUS isn't one). This normally gets
  212. # realigned to a P-prefix local id via the caller's printer_kp
  213. # lookup, but on the replay path in main.py.on_ams_change
  214. # current_user=None skips cloud auth and leaves the raw PFUS in
  215. # tray_info_idx — overwriting the correctly-configured slot from
  216. # the original assign.
  217. # 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
  218. # "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
  219. # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
  220. # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
  221. if tray_info_idx and (
  222. is_material_name(tray_info_idx) or tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")
  223. ):
  224. tray_info_idx = ""
  225. # Preserve setting_id when it's still a valid slicer reference
  226. # (PFUS / PFCN cloud user/shared preset, or GFS Bambu official
  227. # preset). The slicer accepts these as setting_id even though
  228. # they're rejected as tray_info_idx; without preservation the
  229. # slicer falls back to whatever generic filament the caller's
  230. # tray_info_idx fallback produces and shows "Generic <Material>"
  231. # instead of the user's actual custom preset (#1815). Material-name
  232. # leaks (e.g. setting_id="PETG") are still cleared — those are
  233. # never valid slicer references.
  234. if not (
  235. setting_id
  236. and (setting_id.startswith("PFUS") or setting_id.startswith("PFCN") or setting_id.startswith("GFS"))
  237. ):
  238. setting_id = ""
  239. return (tray_info_idx, setting_id, sub_brand_override, type_override)