filament_profile_default.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. """Profile defaults per product: what a new spool starts with (#1326).
  2. A default (``models.filament_profile_default``) holds the slicer preset, the
  3. per-model preset overrides and the K profiles for one product, for all its
  4. colours (``color_name`` "") or for one. ``resolve_profile_default`` merges the
  5. two levels, the colour row winning per preset and per hotend, and the
  6. ``fill_*`` helpers copy the result onto a spool -- filling only what the spool
  7. does not have yet, so they are safe on a create path that was handed some
  8. profiles already and on spools that have been in stock for months.
  9. """
  10. from __future__ import annotations
  11. import logging
  12. from dataclasses import dataclass, field
  13. from sqlalchemy import delete, inspect as sa_inspect, select
  14. from sqlalchemy.ext.asyncio import AsyncSession
  15. from sqlalchemy.orm import selectinload
  16. from backend.app.models.filament_profile_default import (
  17. FilamentProfileDefault,
  18. FilamentProfileDefaultKProfile,
  19. FilamentProfileDefaultPreset,
  20. )
  21. from backend.app.models.spool import Spool
  22. from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
  23. from backend.app.models.spool_k_profile import SpoolKProfile
  24. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  25. logger = logging.getLogger(__name__)
  26. ProductKey = tuple[str, str, str, str] # (brand, material, subtype, color_name)
  27. K_FIELDS = ("printer_id", "extruder", "nozzle_diameter", "nozzle_type", "k_value", "name", "cali_idx", "setting_id")
  28. PRESET_FIELDS = ("printer_model", "nozzle_diameter", "slicer_filament", "slicer_filament_name")
  29. def product_key(*, brand: str | None, material: str | None, subtype: str | None, color_name: str | None) -> ProductKey:
  30. """The key a spool's product fields map to: trimmed, "" for missing."""
  31. return ((brand or "").strip(), (material or "").strip(), (subtype or "").strip(), (color_name or "").strip())
  32. def match_key(key: ProductKey) -> str:
  33. """What ``FilamentProfileDefault.match_key`` stores for a product key, and
  34. what every comparison of product fields here goes through."""
  35. return "\x1f".join(part.lower() for part in key)
  36. def hotend_key(printer_id: int, extruder: int | None, nozzle_diameter: str | None) -> tuple[int, int, str]:
  37. """One hotend: what a spool holds at most one K profile for. The diameter
  38. falls back to "0.4" as the K-profile columns do."""
  39. return (printer_id, int(extruder or 0), (nozzle_diameter or "").strip() or "0.4")
  40. def k_key(row: dict) -> tuple[int, int, str]:
  41. return hotend_key(row["printer_id"], row.get("extruder"), row.get("nozzle_diameter"))
  42. def preset_key(row: dict) -> tuple[str, str]:
  43. return (row.get("printer_model") or "", row.get("nozzle_diameter") or "")
  44. # Spool.slicer_filament / _name are narrower than the default's columns, which
  45. # follow the Spoolman schema. A preset id is never cut short: one that does not
  46. # fit is not filled in at all.
  47. _SPOOL_SLICER_FILAMENT_LEN = 50
  48. _SPOOL_SLICER_FILAMENT_NAME_LEN = 100
  49. @dataclass
  50. class ResolvedProfiles:
  51. """A product's default, both levels merged."""
  52. slicer_filament: str | None = None
  53. slicer_filament_name: str | None = None
  54. presets: list[dict] = field(default_factory=list)
  55. k_profiles: list[dict] = field(default_factory=list)
  56. # The rows it came from, all-colours first.
  57. default_ids: list[int] = field(default_factory=list)
  58. @property
  59. def is_empty(self) -> bool:
  60. return not (self.slicer_filament or self.presets or self.k_profiles)
  61. def _with_children():
  62. return (
  63. selectinload(FilamentProfileDefault.presets),
  64. selectinload(FilamentProfileDefault.k_profiles),
  65. )
  66. async def find_default(db: AsyncSession, key: ProductKey) -> FilamentProfileDefault | None:
  67. """The default stored for exactly this key, case-insensitively."""
  68. result = await db.execute(
  69. select(FilamentProfileDefault)
  70. .options(*_with_children())
  71. .where(FilamentProfileDefault.match_key == match_key(key))
  72. )
  73. return result.scalars().first()
  74. async def resolve_profile_default(
  75. db: AsyncSession,
  76. *,
  77. brand: str | None,
  78. material: str | None,
  79. subtype: str | None,
  80. color_name: str | None,
  81. ) -> ResolvedProfiles | None:
  82. """The profiles a new spool of this product gets, or None when there is no default.
  83. The all-colours row is the base; the row for the spool's colour replaces
  84. its slicer preset when it has one, and each of its presets and K profiles
  85. replaces the base's for the same (model, nozzle) or hotend.
  86. """
  87. key = product_key(brand=brand, material=material, subtype=subtype, color_name=color_name)
  88. if not key[1]:
  89. return None
  90. rows = [await find_default(db, (*key[:3], ""))]
  91. if key[3]:
  92. rows.append(await find_default(db, key))
  93. rows = [r for r in rows if r is not None]
  94. if not rows:
  95. return None
  96. resolved = ResolvedProfiles()
  97. presets: dict[tuple[str, str], dict] = {}
  98. k_profiles: dict[tuple[int, int, str], dict] = {}
  99. for row in rows:
  100. resolved.default_ids.append(row.id)
  101. if row.slicer_filament:
  102. resolved.slicer_filament = row.slicer_filament
  103. resolved.slicer_filament_name = row.slicer_filament_name
  104. for p in row.presets:
  105. preset = {name: getattr(p, name) for name in PRESET_FIELDS}
  106. presets[preset_key(preset)] = preset
  107. for k in row.k_profiles:
  108. k_profile = {name: getattr(k, name) for name in K_FIELDS}
  109. k_profiles[k_key(k_profile)] = k_profile
  110. resolved.presets = list(presets.values())
  111. resolved.k_profiles = list(k_profiles.values())
  112. return resolved
  113. async def save_default(
  114. db: AsyncSession,
  115. key: ProductKey,
  116. *,
  117. slicer_filament: str | None,
  118. slicer_filament_name: str | None,
  119. presets: list[dict],
  120. k_profiles: list[dict],
  121. keep_printer_ids: set[int] | None = None,
  122. ) -> FilamentProfileDefault:
  123. """Create or replace the default for ``key``. The caller commits.
  124. ``keep_printer_ids``: K profiles of these printers are left as they are
  125. instead of being replaced -- the printers a caller limited to some of them
  126. (#1727) cannot see, and so cannot have meant to clear.
  127. """
  128. row = await find_default(db, key)
  129. if row is None:
  130. brand, material, subtype, color_name = key
  131. row = FilamentProfileDefault(
  132. match_key=match_key(key),
  133. brand=brand,
  134. material=material,
  135. subtype=subtype,
  136. color_name=color_name,
  137. presets=[],
  138. k_profiles=[],
  139. )
  140. db.add(row)
  141. await db.flush()
  142. row.slicer_filament = slicer_filament or None
  143. row.slicer_filament_name = (slicer_filament_name or None) if slicer_filament else None
  144. await db.execute(
  145. delete(FilamentProfileDefaultPreset).where(FilamentProfileDefaultPreset.profile_default_id == row.id)
  146. )
  147. k_delete = delete(FilamentProfileDefaultKProfile).where(FilamentProfileDefaultKProfile.profile_default_id == row.id)
  148. if keep_printer_ids:
  149. k_delete = k_delete.where(FilamentProfileDefaultKProfile.printer_id.not_in(keep_printer_ids))
  150. await db.execute(k_delete)
  151. # The deletes before the inserts, or the UNIQUE constraints see both.
  152. await db.flush()
  153. kept = {
  154. hotend_key(k.printer_id, k.extruder, k.nozzle_diameter)
  155. for k in row.k_profiles
  156. if keep_printer_ids and k.printer_id in keep_printer_ids
  157. }
  158. for p in presets:
  159. db.add(FilamentProfileDefaultPreset(profile_default_id=row.id, **{n: p.get(n) for n in PRESET_FIELDS}))
  160. for k in k_profiles:
  161. if k_key(k) in kept:
  162. continue
  163. db.add(FilamentProfileDefaultKProfile(profile_default_id=row.id, **{n: k.get(n) for n in K_FIELDS}))
  164. await db.flush()
  165. db.expire(row, ["presets", "k_profiles", "updated_at"])
  166. return row
  167. async def load_default(db: AsyncSession, default_id: int) -> FilamentProfileDefault | None:
  168. result = await db.execute(
  169. select(FilamentProfileDefault)
  170. .options(*_with_children())
  171. .where(FilamentProfileDefault.id == default_id)
  172. .execution_options(populate_existing=True)
  173. )
  174. return result.scalars().first()
  175. async def fill_internal_spool(
  176. db: AsyncSession,
  177. spool: Spool,
  178. *,
  179. include_slicer: bool = True,
  180. resolved: ResolvedProfiles | None = None,
  181. ) -> bool:
  182. """Copy the product's default onto an internal spool where it has nothing.
  183. Never replaces: a slicer preset, a preset for a (model, nozzle) or a K
  184. profile for a hotend the spool already has stays. ``include_slicer=False``
  185. leaves the spool's own preset alone even when empty. The caller flushes
  186. or commits. Returns whether anything was added.
  187. """
  188. if resolved is None:
  189. resolved = await resolve_profile_default(
  190. db, brand=spool.brand, material=spool.material, subtype=spool.subtype, color_name=spool.color_name
  191. )
  192. if resolved is None or resolved.is_empty:
  193. return False
  194. changed = False
  195. if (
  196. include_slicer
  197. and resolved.slicer_filament
  198. and not spool.slicer_filament
  199. and len(resolved.slicer_filament) <= _SPOOL_SLICER_FILAMENT_LEN
  200. ):
  201. spool.slicer_filament = resolved.slicer_filament
  202. spool.slicer_filament_name = (resolved.slicer_filament_name or "")[:_SPOOL_SLICER_FILAMENT_NAME_LEN] or None
  203. changed = True
  204. have_presets: set[tuple[str, str]] = set(
  205. (
  206. await db.execute(
  207. select(SpoolFilamentPreset.printer_model, SpoolFilamentPreset.nozzle_diameter).where(
  208. SpoolFilamentPreset.spool_id == spool.id
  209. )
  210. )
  211. ).all()
  212. )
  213. for p in resolved.presets:
  214. if preset_key(p) in have_presets:
  215. continue
  216. db.add(SpoolFilamentPreset(spool_id=spool.id, **p))
  217. changed = True
  218. # The RFID assign that follows an auto-create reads the spool's in-memory
  219. # list, which create_spool_from_tray initialised: rows only added to the
  220. # session would miss that assign's K profile.
  221. loaded = "k_profiles" not in sa_inspect(spool).unloaded
  222. if loaded:
  223. have_k = {hotend_key(k.printer_id, k.extruder, k.nozzle_diameter) for k in spool.k_profiles}
  224. else:
  225. have_k = {
  226. hotend_key(pid, ext, dia)
  227. for pid, ext, dia in (
  228. await db.execute(
  229. select(SpoolKProfile.printer_id, SpoolKProfile.extruder, SpoolKProfile.nozzle_diameter).where(
  230. SpoolKProfile.spool_id == spool.id
  231. )
  232. )
  233. ).all()
  234. }
  235. for k in resolved.k_profiles:
  236. if k_key(k) in have_k:
  237. continue
  238. row = SpoolKProfile(spool_id=spool.id, **k)
  239. if loaded:
  240. spool.k_profiles.append(row)
  241. else:
  242. db.add(row)
  243. have_k.add(k_key(k))
  244. changed = True
  245. return changed
  246. async def fill_spoolman_rows(db: AsyncSession, spoolman_spool_id: int, resolved: ResolvedProfiles) -> bool:
  247. """The Bambuddy-side half of ``fill_internal_spool`` for a Spoolman spool:
  248. preset overrides and K profiles, which live here keyed by the remote id.
  249. The spool's own slicer preset lives in Spoolman, see the callers. The
  250. caller commits."""
  251. changed = False
  252. have_presets = set(
  253. (
  254. await db.execute(
  255. select(SpoolmanFilamentPreset.printer_model, SpoolmanFilamentPreset.nozzle_diameter).where(
  256. SpoolmanFilamentPreset.spoolman_spool_id == spoolman_spool_id
  257. )
  258. )
  259. ).all()
  260. )
  261. for p in resolved.presets:
  262. if preset_key(p) in have_presets:
  263. continue
  264. db.add(SpoolmanFilamentPreset(spoolman_spool_id=spoolman_spool_id, **p))
  265. changed = True
  266. have_k = {
  267. hotend_key(pid, ext, dia)
  268. for pid, ext, dia in (
  269. await db.execute(
  270. select(SpoolmanKProfile.printer_id, SpoolmanKProfile.extruder, SpoolmanKProfile.nozzle_diameter).where(
  271. SpoolmanKProfile.spoolman_spool_id == spoolman_spool_id
  272. )
  273. )
  274. ).all()
  275. }
  276. for k in resolved.k_profiles:
  277. if k_key(k) in have_k:
  278. continue
  279. db.add(SpoolmanKProfile(spoolman_spool_id=spoolman_spool_id, **k))
  280. have_k.add(k_key(k))
  281. changed = True
  282. return changed
  283. async def fill_auto_added_spoolman_spool(spool: dict, client=None, *, include_slicer: bool = False) -> None:
  284. """The profile default for a spool the AMS sync just added to Spoolman.
  285. Its preset overrides and K profiles, and -- with ``include_slicer``, for a
  286. tray whose tag names no preset -- the default's own preset, written to the
  287. spool's extra dict through ``client``. A tag's preset is left alone, as for
  288. an internal RFID spool. In a session of its own because the sync's callers
  289. do not commit, and a failure is only logged: the spool is in Spoolman
  290. either way.
  291. """
  292. import json
  293. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  294. from backend.app.core.database import async_session
  295. from backend.app.services.spool_filament_preset import forget_spoolman_spool_own_preset
  296. try:
  297. mapped = _map_spoolman_spool(spool)
  298. async with async_session() as db:
  299. resolved = await resolve_profile_default(
  300. db,
  301. brand=mapped["brand"],
  302. material=mapped["material"],
  303. subtype=mapped["subtype"],
  304. color_name=None if mapped["color_name_is_synthesized"] else mapped["color_name"],
  305. )
  306. if resolved is None:
  307. return
  308. if await fill_spoolman_rows(db, mapped["id"], resolved):
  309. await db.commit()
  310. if include_slicer and client is not None and resolved.slicer_filament and not mapped["slicer_filament"]:
  311. extra = {"bambu_slicer_filament": json.dumps(resolved.slicer_filament)}
  312. if resolved.slicer_filament_name:
  313. extra["bambu_slicer_filament_name"] = json.dumps(resolved.slicer_filament_name)
  314. for name in extra:
  315. await client.ensure_extra_field(name)
  316. await client.merge_spool_extra(mapped["id"], extra)
  317. forget_spoolman_spool_own_preset(mapped["id"])
  318. except Exception as exc: # noqa: BLE001 — never fail the AMS sync over a default
  319. logger.warning("Profile default for auto-added Spoolman spool %s not applied: %s", spool.get("id"), exc)
  320. def default_matches(row: FilamentProfileDefault, *, brand, material, subtype, color_name) -> bool:
  321. """Whether a spool with these fields is covered by ``row`` (its colour, or
  322. any colour for an all-colours row)."""
  323. spool = match_key(product_key(brand=brand, material=material, subtype=subtype, color_name=color_name)).split("\x1f")
  324. want = row.match_key.split("\x1f")
  325. return spool[:3] == want[:3] and (not want[3] or spool[3] == want[3])
  326. async def apply_to_internal_spools(db: AsyncSession, row: FilamentProfileDefault) -> int:
  327. """Fill every active spool ``row`` covers. Returns how many changed; the
  328. caller commits."""
  329. # Matched in Python, with the same fold as the defaults' match_key.
  330. candidates = (await db.execute(select(Spool).where(Spool.archived_at.is_(None)))).scalars().all()
  331. spools = [
  332. s
  333. for s in candidates
  334. if default_matches(row, brand=s.brand, material=s.material, subtype=s.subtype, color_name=s.color_name)
  335. ]
  336. changed = 0
  337. cache: dict[str, ResolvedProfiles | None] = {}
  338. for spool in spools:
  339. # Each spool resolves on its own colour: applying the all-colours row
  340. # to a colour that has a row of its own still gives that colour its values.
  341. colour = (spool.color_name or "").strip().lower()
  342. if colour not in cache:
  343. cache[colour] = await resolve_profile_default(
  344. db, brand=row.brand, material=row.material, subtype=row.subtype, color_name=spool.color_name
  345. )
  346. if await fill_internal_spool(db, spool, resolved=cache[colour]):
  347. changed += 1
  348. return changed