slicer_filament_resolver.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  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 dataclasses import dataclass
  34. from sqlalchemy import select
  35. from sqlalchemy.ext.asyncio import AsyncSession
  36. from backend.app.core.permissions import Permission
  37. from backend.app.models.user import User
  38. from backend.app.utils.filament_finish import different_finish
  39. from backend.app.utils.filament_ids import (
  40. GENERIC_FILAMENT_IDS,
  41. GENERIC_IDS_REPLACED_ON_REUSE,
  42. filament_id_to_setting_id,
  43. normalize_slicer_filament,
  44. orca_to_bambu_filament_id,
  45. setting_id_to_filament_id,
  46. )
  47. from backend.app.utils.filament_types import is_material_name
  48. from backend.app.utils.slicer_strings import first_slicer_string
  49. logger = logging.getLogger(__name__)
  50. # Orca Cloud profile ids are UUIDs, the one preset reference in this codebase
  51. # with no letter prefix to key off. A spool stores the bare id (the spool form
  52. # persists ``preset.setting_id`` verbatim), so shape is all there is to go on.
  53. _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}")
  54. @dataclass(frozen=True)
  55. class OrcaFilamentLookup:
  56. """Outcome of resolving an Orca Cloud profile to a printer filament id.
  57. ``filament_id`` is empty when nothing usable was found; ``reason`` then says
  58. why, so a caller in front of a user can say so instead of quietly sending
  59. a generic id (#3216). ``source`` is ``"own"`` for the profile's own id,
  60. ``"inherited"`` for one taken from a parent.
  61. """
  62. filament_id: str = ""
  63. name: str | None = None
  64. filament_type: str | None = None
  65. source: str = ""
  66. reason: str = ""
  67. # How many ``inherits`` hops to follow before giving up. Orca presets are one
  68. # or two deep in practice; the cap only guards against a cycle in hand-edited
  69. # profiles.
  70. _MAX_INHERITS_DEPTH = 5
  71. def _content_filament_id(content: dict) -> str:
  72. """A profile's filament_id as the printer knows it.
  73. One copied from an OrcaSlicer system preset is Orca's own id ("OFMUWNkp"),
  74. which the printer cannot resolve; it is translated with Orca's own table,
  75. and one the table does not know reads as none, so the lookup goes on to
  76. the parents (#3387)."""
  77. raw = content.get("filament_id")
  78. if isinstance(raw, list):
  79. raw = raw[0] if raw else None
  80. return orca_to_bambu_filament_id(raw.strip()) if isinstance(raw, str) else ""
  81. def _catalog_filament_id(preset_name: str) -> str:
  82. """Bambu catalog id for a Bambu system preset name, or ``""``.
  83. An Orca profile that inherits a system preset ("Bambu PLA Basic @BBL H2D")
  84. carries no id of its own. Its parent's id is the one the printer and the
  85. slicer both know -- OrcaSlicer since 2.5 translates the Bambu catalog ids
  86. at the printer boundary -- so it beats a generic for the material.
  87. """
  88. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  89. base = preset_name.split("@")[0].strip().lstrip("#").strip().lower()
  90. if not base:
  91. return ""
  92. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  93. if fname.lower() == base:
  94. return fid
  95. return ""
  96. async def lookup_orca_filament_id(
  97. db: AsyncSession,
  98. current_user: User | None,
  99. profile_id: str,
  100. ) -> OrcaFilamentLookup:
  101. """Look up the filament id an Orca Cloud profile puts in an AMS slot.
  102. OrcaSlicer resolves a slot to a preset by ``filament_id`` alone, so this is
  103. the value that decides whether "Sync filaments" finds the user's profile or
  104. falls back to Generic (#3216). Tried in order: the profile's own id; the
  105. nearest parent among the user's Orca profiles that has one; the Bambu
  106. catalog id of a Bambu system parent.
  107. Best-effort by construction: this runs inside spool assignment and slot
  108. configuration, so a missing pairing, a revoked token or a lapsed permission
  109. must degrade to the caller's fallback rather than fail the request. That is
  110. also why ``clear_on_auth_failure=False`` -- Orca reports every refresh
  111. rejection with one composite reason, so this caller cannot tell a real
  112. revocation from a lost rotation race and must not wipe a working pairing on
  113. it. The Orca Cloud routes hit the same failure in front of a user and clear
  114. there.
  115. """
  116. if current_user is not None and not current_user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
  117. logger.info("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
  118. return OrcaFilamentLookup(reason="no_permission")
  119. svc = None
  120. try:
  121. from backend.app.api.routes.orca_cloud import _build_authenticated_service
  122. svc = await _build_authenticated_service(db, current_user, clear_on_auth_failure=False)
  123. profile = await svc.get_profile(profile_id)
  124. content = profile.get("content") if isinstance(profile, dict) else None
  125. if not isinstance(content, dict):
  126. logger.info("Orca filament lookup for %r: profile has no content", profile_id)
  127. return OrcaFilamentLookup(reason="no_filament_id")
  128. raw_name = profile.get("name")
  129. name = raw_name if isinstance(raw_name, str) and raw_name else None
  130. filament_type = _preset_filament_type(content.get("filament_type"))
  131. own = _content_filament_id(content)
  132. if own:
  133. logger.info("Orca filament lookup for %r: own filament_id %r", profile_id, own)
  134. return OrcaFilamentLookup(own, name, filament_type, source="own")
  135. parent = content.get("inherits")
  136. parent = parent.strip() if isinstance(parent, str) else ""
  137. by_name: dict[str, dict] | None = None
  138. for _ in range(_MAX_INHERITS_DEPTH):
  139. if not parent:
  140. break
  141. if by_name is None:
  142. # One more pull, only for a profile that needs its parent. A
  143. # failure here keeps the name and type already read, as the
  144. # lookup returned them before it followed parents.
  145. by_name = {}
  146. try:
  147. entries = await svc.list_profiles()
  148. except Exception as e:
  149. logger.info("Orca filament lookup for %r: could not read its parents: %s", profile_id, e)
  150. return OrcaFilamentLookup(name=name, filament_type=filament_type, reason="lookup_failed")
  151. for entry in entries:
  152. entry_content = entry.get("content") if isinstance(entry, dict) else None
  153. if not isinstance(entry_content, dict):
  154. continue
  155. for key in (entry.get("name"), entry_content.get("name")):
  156. if isinstance(key, str) and key:
  157. by_name.setdefault(key, entry_content)
  158. parent_content = by_name.get(parent)
  159. if parent_content is None:
  160. catalog = _catalog_filament_id(parent)
  161. if catalog:
  162. logger.info(
  163. "Orca filament lookup for %r: catalog filament_id %r of system parent %r",
  164. profile_id,
  165. catalog,
  166. parent,
  167. )
  168. return OrcaFilamentLookup(catalog, name, filament_type, source="inherited")
  169. break
  170. inherited = _content_filament_id(parent_content)
  171. if inherited:
  172. logger.info(
  173. "Orca filament lookup for %r: filament_id %r inherited from %r", profile_id, inherited, parent
  174. )
  175. return OrcaFilamentLookup(inherited, name, filament_type, source="inherited")
  176. next_parent = parent_content.get("inherits")
  177. parent = next_parent.strip() if isinstance(next_parent, str) else ""
  178. logger.info("Orca filament lookup for %r: no filament_id on the profile or its parents", profile_id)
  179. return OrcaFilamentLookup(name=name, filament_type=filament_type, reason="no_filament_id")
  180. except Exception as e:
  181. # INFO, not DEBUG: this is the line that explains a slot coming out as
  182. # Generic in a support bundle (#3216).
  183. logger.info("Orca filament lookup failed for %r: %s", profile_id, e)
  184. return OrcaFilamentLookup(reason="lookup_failed")
  185. finally:
  186. # A raise in `finally` escapes the `except` above, so guard it: closing
  187. # an httpx client must never be what fails a spool assignment.
  188. if svc is not None:
  189. try:
  190. await svc.close()
  191. except Exception as e: # noqa: BLE001 - close() is best-effort
  192. logger.debug("Orca client close failed after lookup of %r: %s", profile_id, e)
  193. async def _orca_filament_id(
  194. db: AsyncSession,
  195. current_user: User | None,
  196. profile_id: str,
  197. ) -> tuple[str, str | None, str | None]:
  198. """``(filament_id, name, filament_type)`` for the spool resolver below --
  199. all empty/None when nothing was found, which leaves it on its generic
  200. fallback. See ``lookup_orca_filament_id``."""
  201. found = await lookup_orca_filament_id(db, current_user, profile_id)
  202. return (found.filament_id, found.name, found.filament_type)
  203. def _preset_filament_type(raw: object) -> str | None:
  204. """Read a slicer preset's ``filament_type`` field.
  205. Bambu Studio and OrcaSlicer both store it as a one-element array
  206. (``["PLA"]``); some hand-written and older profiles store a bare string,
  207. and Orca Cloud can return it INI-quoted (``'"PLA"'``, #3380).
  208. ``orca_profiles._extract_filament_fields`` accepts both and this has to
  209. agree with it, since that is what fills ``LocalPreset.filament_type``.
  210. The value is still run through ``printer_filament_type`` by the caller.
  211. That is a no-op for every type the app knows -- ``TestTheMaterialsBambuddyOffers``
  212. pins exactly that -- and a pass-through for a type it does not, so nothing
  213. the slicer says is discarded. It only bites on a hand-edited profile whose
  214. ``filament_type`` is a product line, which is the case this whole module
  215. exists to keep out of an AMS slot.
  216. """
  217. return first_slicer_string(raw)
  218. def reusable_slot_filament_id(
  219. current_tray_info_idx: str | None,
  220. current_tray_type: str | None,
  221. current_tray_sub_brands: str | None,
  222. tray_type: str,
  223. spool_text: str,
  224. ) -> str:
  225. """The slot's own filament id, when a spool without a preset may keep it.
  226. A spool whose preset is empty or unresolvable would otherwise get the
  227. generic id for its material, which turns a genuine Bambu PLA Basic into
  228. Generic PLA. When the slot already holds a specific filament id of the same
  229. type, keeping it is closer. Shared by both inventory modes (#3433): the
  230. Spoolman route went straight to the generic id.
  231. Not kept: a generic id, a cloud user preset (PFUS/PFCN), a material name
  232. left in that field by a Bambuddy from before #2902, another type, and
  233. another finish when both name one (a Silk spool does not take a Matte id).
  234. ``spool_text`` is the spool's material and subtype.
  235. """
  236. current = (current_tray_info_idx or "").strip()
  237. if (
  238. current
  239. and current not in GENERIC_IDS_REPLACED_ON_REUSE
  240. and not current.startswith("PFUS")
  241. and not current.startswith("PFCN")
  242. and not is_material_name(current)
  243. and current_tray_type
  244. and current_tray_type.upper() == tray_type.upper()
  245. and not different_finish(spool_text, current_tray_sub_brands)
  246. ):
  247. return current
  248. return ""
  249. async def resolve_slicer_filament(
  250. *,
  251. db: AsyncSession,
  252. current_user: User | None,
  253. slicer_filament: str | None,
  254. slicer_filament_name: str | None,
  255. material: str | None,
  256. ) -> tuple[str, str, str | None, str | None]:
  257. """Resolve a spool's slicer-preset reference to printer-side ids.
  258. ``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
  259. ``"PFUS990b6e19965353"``, ``"38"`` for a numeric LocalPreset id, or
  260. free-text). May be empty or None — returns the empty tuple in that case.
  261. ``slicer_filament_name``: optional builtin-name realignment hint. When
  262. set and the resolved tray_info_idx maps to a different builtin name,
  263. the resolver swaps to the builtin whose name matches (e.g. user picked
  264. "Bambu PLA Matte" but the cloud lookup landed on "Bambu PLA Basic").
  265. ``material``: spool material string for the local-preset fallback
  266. branch when the LocalPreset's setting JSON doesn't carry a filament_id.
  267. Returns ``(tray_info_idx, setting_id, sub_brand_override, type_override)``
  268. — all empty when nothing resolved. ``sub_brand_override`` is non-None when
  269. a more specific brand label is available (cloud detail name or local preset
  270. name); ``None`` means the caller should use its own default.
  271. ``type_override`` is the preset's own ``filament_type`` when the preset
  272. carries one — the slicer's answer to what the material is, rather than one
  273. parsed out of the spool's material column. It is what the caller should
  274. write into ``tray_type``. ``None`` means no preset said, and the caller
  275. falls back to reducing the spool's material (``printer_filament_type``).
  276. Raised in the #2902 thread by @doncaruana: a preset has to be chosen from
  277. a list the slicer defines, so its type needs no interpreting. It cannot be
  278. the only source, though — ``slicer_filament`` is nullable on a spool while
  279. ``material`` is required, and the spool this issue was reported for had no
  280. preset at all.
  281. """
  282. sf = (slicer_filament or "").strip()
  283. if not sf:
  284. return ("", "", None, None)
  285. tray_info_idx = ""
  286. setting_id = ""
  287. sub_brand_override: str | None = None
  288. type_override: str | None = None
  289. base_sf = sf.split("_")[0] if "_" in sf else sf
  290. # Cloud-side preset IDs in three known shapes:
  291. # GFS… — Bambu official cloud preset
  292. # PFUS… — cloud user-created preset
  293. # PFCN… — cloud shared / partner preset (e.g. Polymaker's "(Custom)"
  294. # Bambu Lab H2D variant, #1648)
  295. # All three need a cloud-detail lookup to extract the underlying
  296. # filament_id; without it the raw cloud id ends up in tray_info_idx
  297. # and the printer's calibration table can't resolve it.
  298. # Source order is Orca Cloud, Bambu Cloud, local import, generic fallback.
  299. # Orca goes first because its ids are the only ones identified by shape
  300. # rather than prefix -- and because, before #3003, a UUID fell through every
  301. # branch below into ``normalize_slicer_filament``, which passes anything it
  302. # does not recognise straight through. A 36-character UUID then went into
  303. # tray_info_idx, an 8-character field, and the slot ended up pointing at the
  304. # first 8 characters of a UUID: the same failure the PFUS guard at the
  305. # bottom of this function exists for.
  306. if _ORCA_PROFILE_ID.fullmatch(base_sf):
  307. tray_info_idx, orca_name, orca_type = await _orca_filament_id(db, current_user, base_sf)
  308. if orca_type:
  309. type_override = orca_type
  310. if orca_name:
  311. sub_brand_override = orca_name.split("@")[0].strip()
  312. # setting_id is left empty here: the UUID is what the slicer cannot
  313. # resolve, and unlike a PFUS there is no cloud id form it accepts
  314. # instead. All three callers then derive one from the filament_id
  315. # (`filament_id_to_setting_id`), which is what keeps the slot from
  316. # going out half configured -- the same path a local import takes.
  317. elif base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
  318. setting_id = base_sf
  319. try:
  320. from backend.app.api.routes.cloud import build_authenticated_cloud
  321. cloud = await build_authenticated_cloud(db, current_user)
  322. if cloud is not None and cloud.is_authenticated:
  323. try:
  324. detail = await cloud.get_setting_detail(base_sf)
  325. # The preset's own type, straight from the slicer's own
  326. # profile -- no parsing of a product name (#2902). The
  327. # preset JSON is nested under ``setting``; some responses
  328. # carry it at the top level instead, the same shape spread
  329. # ``preset_resolver`` documents.
  330. cloud_setting = detail.get("setting")
  331. type_override = _preset_filament_type(
  332. (cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
  333. )
  334. # A custom preset's OWN filament_id is the only thing that
  335. # gets it into an AMS slot as itself: the printer stores
  336. # that id, the slicer matches its presets against it, and
  337. # the 8-character field fits it exactly ("P" + 7 hex).
  338. #
  339. # Bambu Cloud normally returns it on the envelope, which is
  340. # what the captures in #1053 show for a Studio-created
  341. # preset (filament_id: "Pbd31b30"). The `setting` fallback
  342. # here is belt-and-braces for a response that carries it in
  343. # the preset JSON instead, the same spread `filament_type`
  344. # above has to handle -- no captured response has needed it
  345. # yet, and it costs a dict lookup to be ready for one.
  346. #
  347. # An Orca-created preset has no filament_id anywhere: the
  348. # envelope says null and `setting` is a delta from the base
  349. # (#1053 again). Those legitimately fall to base_id below
  350. # and reach the slicer as the profile they inherit from --
  351. # an OrcaSlicer preset-format gap, filed upstream as
  352. # OrcaSlicer PR #13315, not something resolvable here.
  353. own_filament_id = detail.get("filament_id") or (
  354. cloud_setting.get("filament_id") if isinstance(cloud_setting, dict) else None
  355. )
  356. # An official preset's id comes from its full setting_id
  357. # (#3387). The detail read above is the suffix-free base's,
  358. # and for some bases that is another filament: "GFSA05" is
  359. # Bambu PLA Matte @BBL X1, while "GFSA05_07" -- and the
  360. # bare id Bambuddy builds from GFA05 -- is Bambu PLA Silk.
  361. # Its type and name only count when it is the same filament.
  362. # A spool named after that very preset is the exception: it
  363. # was picked as Bambu's legacy bare preset ("Bambu TPU 95A
  364. # @BBL X1C", "GFSU00" -> GFU01), not built by Bambuddy, and
  365. # Bambu's own id is then the right one.
  366. official = setting_id_to_filament_id(sf) if base_sf.startswith("GFS") else ""
  367. detail_name = detail.get("name")
  368. if (
  369. official
  370. and own_filament_id
  371. and isinstance(detail_name, str)
  372. and detail_name.strip()
  373. and detail_name.strip() == (slicer_filament_name or "").strip()
  374. ):
  375. official = own_filament_id
  376. if official:
  377. tray_info_idx = official
  378. if own_filament_id and own_filament_id != official:
  379. type_override = None
  380. else:
  381. cloud_name = detail.get("name", "")
  382. if cloud_name:
  383. sub_brand_override = cloud_name.split("@")[0].strip()
  384. elif own_filament_id:
  385. tray_info_idx = own_filament_id
  386. cloud_name = detail.get("name", "")
  387. if cloud_name:
  388. sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
  389. elif detail.get("base_id"):
  390. tray_info_idx = setting_id_to_filament_id(detail["base_id"])
  391. finally:
  392. await cloud.close()
  393. elif cloud is not None:
  394. await cloud.close()
  395. except Exception as e:
  396. logger.warning("Slicer-filament resolve: cloud lookup failed for %r: %s", sf, e)
  397. if not tray_info_idx:
  398. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  399. elif base_sf.startswith("GF"):
  400. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  401. else:
  402. try:
  403. local_id = int(sf)
  404. from backend.app.models.local_preset import LocalPreset as LP
  405. lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
  406. lp = lp_result.scalar_one_or_none()
  407. if lp:
  408. # The slicer's own answer, extracted from the profile at import
  409. # time by ``orca_profiles``. Preferred over anything parsed out
  410. # of the spool's material column (#2902).
  411. type_override = _preset_filament_type(lp.filament_type)
  412. # Local preset's setting JSON carries the printer-recognized
  413. # filament_id (e.g. "P4d64437") — use that directly so the
  414. # slicer can resolve the specific preset. Falls through to
  415. # generic material id only when the JSON doesn't carry one.
  416. lp_filament_id = ""
  417. if lp.setting:
  418. try:
  419. setting_data = json.loads(lp.setting)
  420. raw_fid = setting_data.get("filament_id")
  421. if isinstance(raw_fid, str) and raw_fid:
  422. lp_filament_id = raw_fid
  423. except (json.JSONDecodeError, AttributeError):
  424. pass
  425. if lp_filament_id:
  426. tray_info_idx = lp_filament_id
  427. setting_id = filament_id_to_setting_id(lp_filament_id)
  428. else:
  429. # Deliberately not widened to cover product-line materials
  430. # ("PLA+", "HTPLA") the way the callers' own fallbacks were
  431. # (#2902). Returning an id here rather than "" would skip
  432. # the caller's whole no-id block, and with it the slot-reuse
  433. # branch that keeps a printer's calibrated preset -- so the
  434. # widening belongs there, after reuse has had its turn.
  435. mat = (material or lp.filament_type or "").upper().strip()
  436. tray_info_idx = (
  437. GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
  438. )
  439. if lp.name:
  440. sub_brand_override = lp.name.split("@")[0].strip()
  441. except (ValueError, TypeError):
  442. tray_info_idx, setting_id = normalize_slicer_filament(sf)
  443. # Realign tray_info_idx to a builtin whose name matches slicer_filament_name
  444. # when the current resolution lands on a builtin with a different name
  445. # (e.g. cloud detail returned PLA Basic but the spool was labelled PLA Matte).
  446. if tray_info_idx and slicer_filament_name:
  447. from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
  448. expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
  449. if expected_name and expected_name != slicer_filament_name:
  450. for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
  451. if fname == slicer_filament_name:
  452. tray_info_idx = fid
  453. setting_id = filament_id_to_setting_id(fid)
  454. break
  455. # Defend against tray_info_idx values the slicer cannot resolve. Three
  456. # shapes leak through and must be discarded so the caller's generic-
  457. # material fallback can rescue the slot:
  458. # 1. Literal material names ("PLA", "PETG-CF") that pass through
  459. # normalize_slicer_filament unchanged when the spool's slicer_filament
  460. # is free-text rather than a real preset ID. Product lines ("PLA+",
  461. # "HTPLA") count as material names too -- see is_material_name, which
  462. # is shared with the slot-reuse check that must agree with this.
  463. # 2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
  464. # by the slicer as tray_info_idx (the printer's calibration table
  465. # indexes by filament_id, and a PFUS isn't one). This normally gets
  466. # realigned to a P-prefix local id via the caller's printer_kp
  467. # lookup, but on the replay path in main.py.on_ams_change
  468. # current_user=None skips cloud auth and leaves the raw PFUS in
  469. # tray_info_idx — overwriting the correctly-configured slot from
  470. # the original assign.
  471. # 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
  472. # "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
  473. # 4. Orca Cloud profile UUIDs, when the branch above could not reach the
  474. # profile to trade one for its filament_id (#3003). Worst of the four
  475. # at 36 characters against an 8-character field.
  476. # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
  477. # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
  478. if tray_info_idx and (
  479. is_material_name(tray_info_idx)
  480. or tray_info_idx.startswith("PFUS")
  481. or tray_info_idx.startswith("PFCN")
  482. or _ORCA_PROFILE_ID.fullmatch(tray_info_idx)
  483. ):
  484. tray_info_idx = ""
  485. # Preserve setting_id when it's still a valid slicer reference
  486. # (PFUS / PFCN cloud user/shared preset, or GFS Bambu official
  487. # preset). The slicer accepts these as setting_id even though
  488. # they're rejected as tray_info_idx; without preservation the
  489. # slicer falls back to whatever generic filament the caller's
  490. # tray_info_idx fallback produces and shows "Generic <Material>"
  491. # instead of the user's actual custom preset (#1815). Material-name
  492. # leaks (e.g. setting_id="PETG") are still cleared — those are
  493. # never valid slicer references.
  494. if not (
  495. setting_id
  496. and (setting_id.startswith("PFUS") or setting_id.startswith("PFCN") or setting_id.startswith("GFS"))
  497. ):
  498. setting_id = ""
  499. return (tray_info_idx, setting_id, sub_brand_override, type_override)