_spoolman_helpers.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. """Pure helper functions for Spoolman spool mapping.
  2. No heavy dependencies — importable in unit tests without the full backend stack.
  3. """
  4. from __future__ import annotations
  5. import json
  6. import logging
  7. import math
  8. import re
  9. from typing import Any
  10. from typing_extensions import TypedDict
  11. from backend.app.api.routes._url_safety import assert_safe_lan_service_url
  12. logger = logging.getLogger(__name__)
  13. class MappedSpoolFields(TypedDict):
  14. """Full shape of the dict returned by _map_spoolman_spool (InventorySpool-compatible)."""
  15. id: int
  16. material: str | None
  17. subtype: str | None
  18. brand: str | None
  19. color_name: str | None
  20. color_name_is_synthesized: bool
  21. rgba: str | None
  22. extra_colors: str | None
  23. effect_type: None
  24. label_weight: int | None
  25. core_weight: int | None
  26. core_weight_is_inherited: bool
  27. core_weight_catalog_id: None
  28. weight_used: float | None
  29. weight_used_baseline: float | None
  30. weight_locked: bool
  31. last_scale_weight: None
  32. last_weighed_at: None
  33. slicer_filament: None
  34. slicer_filament_name: str | None
  35. nozzle_temp_min: int | None
  36. nozzle_temp_max: None
  37. note: str | None
  38. added_full: None
  39. last_used: str | None
  40. # Last drying (#2863), stored under the bambu_last_dried_* extra keys.
  41. last_dried_at: str | None
  42. last_dried_temp: int | None
  43. last_dried_hours: float | None
  44. encode_time: str | None
  45. tag_uid: str | None
  46. tray_uuid: str | None
  47. data_origin: str | None
  48. tag_type: str | None
  49. archived_at: str | None
  50. created_at: str | None # None when Spoolman spool has no registered timestamp
  51. updated_at: str | None
  52. cost_per_kg: float | None
  53. # What prints are charged per kg: the spool's own price, else its
  54. # filament's catalogue price (SM-C8). Display only; the form edits
  55. # cost_per_kg, so the catalogue price is never written onto the spool.
  56. effective_cost_per_kg: float | None
  57. # Spoolman's native filament.article_number, surfaced as the internal
  58. # material number (#2870). Read-only in Spoolman mode — the number is
  59. # filament-level there and maintained in Spoolman itself.
  60. material_number: str | None
  61. storage_location: str | None
  62. location_id: int | None
  63. k_profiles: list[Any]
  64. class NormalizedVendorRef(TypedDict):
  65. """Vendor reference embedded in a NormalizedFilament."""
  66. id: int
  67. name: str
  68. class NormalizedFilament(TypedDict):
  69. """Normalised Spoolman filament dict returned by the /filaments catalog endpoint."""
  70. id: int
  71. name: str
  72. material: str | None
  73. color_hex: str | None
  74. color_name: str | None
  75. weight: int | None
  76. spool_weight: float | None
  77. vendor: NormalizedVendorRef | None
  78. def assert_safe_spoolman_url(url: str) -> None:
  79. """Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
  80. Thin wrapper over the shared LAN-service policy — see
  81. ``_url_safety.assert_safe_lan_service_url`` for what is and isn't
  82. rejected, and why loopback/RFC-1918 are deliberately permitted (running
  83. Spoolman on the same host or home LAN is THE normal topology).
  84. Kept as a named function because the "Spoolman URL …" wording in its
  85. errors is user-facing and asserted by existing tests.
  86. """
  87. assert_safe_lan_service_url(url, label="Spoolman URL")
  88. # Six characters, or eight when the filament carries an alpha byte. The write
  89. # side stores eight only for genuinely translucent spools (#2912); rejecting
  90. # them here turned every clear spool into neutral grey on read.
  91. _COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$")
  92. _TAG_HEX_RE = re.compile(r"^[0-9A-F]+$")
  93. def _safe_int(value: object, fallback: int) -> int:
  94. """Convert value to int, returning fallback for None/NaN/Inf/non-numeric."""
  95. try:
  96. f = float(value) # type: ignore[arg-type]
  97. if math.isfinite(f):
  98. return int(f)
  99. except (TypeError, ValueError):
  100. pass
  101. return fallback
  102. def _safe_float(value: object, fallback: float) -> float:
  103. """Convert value to float, returning fallback for None/NaN/Inf/non-numeric."""
  104. try:
  105. f = float(value) # type: ignore[arg-type]
  106. if math.isfinite(f):
  107. return f
  108. except (TypeError, ValueError):
  109. pass
  110. return fallback
  111. def _safe_optional_float(value: object) -> float | None:
  112. """Convert value to finite float, or None if missing/NaN/Infinite/non-numeric.
  113. Used for optional monetary fields (price) to prevent Infinity/NaN from
  114. reaching JSON serialisation, which raises ValueError with allow_nan=False.
  115. """
  116. if value is None:
  117. return None
  118. try:
  119. f = float(value) # type: ignore[arg-type]
  120. if math.isfinite(f):
  121. return f
  122. except (TypeError, ValueError):
  123. pass
  124. return None
  125. # Used only when Spoolman has no empty-spool weight at any level.
  126. SPOOLMAN_FALLBACK_TARE = 250.0
  127. def spoolman_tare(spool: dict) -> tuple[float, str]:
  128. """The empty-spool weight of a Spoolman spool, and where it came from.
  129. Spoolman resolves the tare as the spool's own ``spool_weight``, then the
  130. filament's ``spool_weight``, then the vendor's ``empty_spool_weight``, and
  131. its own ``/measure`` endpoint follows that order. Skipping the vendor
  132. level made a spool whose tare lives only on its vendor weigh against
  133. 250 g instead (#3195). Every Spoolman-mode tare in Bambuddy goes through
  134. here so the weigh endpoints and the displayed core weight cannot drift
  135. apart again.
  136. Returns ``(grams, source)`` with source one of ``"spool"``,
  137. ``"filament"``, ``"vendor"`` or ``"fallback"``. 0 is a real tare, not a
  138. missing one; a value that is not a finite number counts as missing.
  139. """
  140. filament = spool.get("filament") or {}
  141. vendor = filament.get("vendor") or {}
  142. for source, raw in (
  143. ("spool", spool.get("spool_weight")),
  144. ("filament", filament.get("spool_weight")),
  145. ("vendor", vendor.get("empty_spool_weight")),
  146. ):
  147. value = _safe_optional_float(raw)
  148. if value is not None:
  149. return value, source
  150. return SPOOLMAN_FALLBACK_TARE, "fallback"
  151. def spoolman_net_weight(spool: dict) -> float | None:
  152. """The net filament weight of a full Spoolman spool (its label weight).
  153. Spoolman keeps it on the spool as ``initial_weight`` and falls back to the
  154. filament's catalogue ``weight``, so one filament can have spools of
  155. different sizes. Reading only the filament made a 250 g spool of a 1000 g
  156. filament look four times its size (#3194). Like Spoolman, an
  157. ``initial_weight`` of 0 counts as unset.
  158. Returns None when neither level has a finite number (a bool is not
  159. one); the filament's value is otherwise returned as stored, 0 included,
  160. so callers keep their own handling of a filament without a weight.
  161. """
  162. initial = _weight_or_none(spool.get("initial_weight"))
  163. if initial is not None and initial > 0:
  164. return initial
  165. filament = spool.get("filament")
  166. return _weight_or_none(filament.get("weight")) if isinstance(filament, dict) else None
  167. def _weight_or_none(value: object) -> float | None:
  168. # A bool is an int in Python; True must not read as a 1 g spool.
  169. return None if isinstance(value, bool) else _safe_optional_float(value)
  170. def spoolman_price_to_cost_per_kg(price: object, net_weight: float) -> float | None:
  171. """A Spoolman spool price as a price per kilogram.
  172. Spoolman's ``price`` on a spool is what that spool cost, not a rate; the
  173. spool form edits a rate, so the two are converted at the spool's net
  174. weight. They agree only for 1000 g spools (#3194). Rounded to 4 places so
  175. a round trip through the form doesn't drift by float noise.
  176. """
  177. value = _safe_optional_float(price)
  178. if value is None or net_weight <= 0:
  179. return None
  180. return round(value * 1000.0 / net_weight, 4)
  181. def cost_per_kg_to_spoolman_price(cost_per_kg: float, net_weight: float) -> float:
  182. """Inverse of spoolman_price_to_cost_per_kg: the price of a spool of ``net_weight`` grams."""
  183. return round(cost_per_kg * net_weight / 1000.0, 4)
  184. def spoolman_price_weight(spool: dict) -> float:
  185. """The weight a Spoolman spool's price is converted at.
  186. The spool's exact net weight, not the whole-gram label weight, so a
  187. 250.7 g spool is not re-priced by a no-op edit. A spool with no usable
  188. weight keeps the old 1:1 reading of its price as a per-kg rate.
  189. """
  190. net = spoolman_net_weight(spool)
  191. return net if net is not None and net > 0 else 1000.0
  192. def spoolman_cost_per_kg(spool: dict) -> float | None:
  193. """The spool form's cost per kg for a Spoolman spool (#3194)."""
  194. return spoolman_price_to_cost_per_kg(spool.get("price"), spoolman_price_weight(spool))
  195. def _charged_cost_per_kg(spool: dict) -> float | None:
  196. """The per-kg rate a print off this spool is charged at, from the same
  197. function the charge uses, so the Inventory shows what prints cost."""
  198. from backend.app.services.spoolman_tracking import _spool_cost_per_gram
  199. per_gram = _spool_cost_per_gram(spool)
  200. return round(per_gram * 1000.0, 4) if per_gram is not None else None
  201. def _extract_extra_str(extra: dict, key: str) -> str:
  202. """Extract a JSON-encoded string from a Spoolman extra dict.
  203. Spoolman stores extra values as JSON-stringified text — a stored string
  204. "GFL05" appears as `'"GFL05"'` (six chars including the quotes). This
  205. unwraps that, returning the bare string. Returns "" for missing keys,
  206. non-strings, or invalid JSON.
  207. """
  208. raw = extra.get(key)
  209. if not isinstance(raw, str):
  210. return ""
  211. try:
  212. decoded = json.loads(raw)
  213. except (json.JSONDecodeError, ValueError):
  214. # Tolerate bare-string values written without JSON encoding.
  215. return raw
  216. return decoded if isinstance(decoded, str) else ""
  217. # Spool.extra key holding the consumed-counter baseline: the value Spoolman's
  218. # used_weight had when the user last pressed "Reset usage to 0". Displayed
  219. # consumed is used_weight minus this, which is what internal mode has always
  220. # done with its own weight_used_baseline column (#1644). See #2906.
  221. BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
  222. # Spool.extra keys holding when the spool was last dried, the target °C and the
  223. # hours the cycle ran (#2863). Written by services/spool_drying.py and the
  224. # spool update route; an empty string means unknown or cleared.
  225. BAMBU_LAST_DRIED_AT_KEY = "bambu_last_dried_at"
  226. BAMBU_LAST_DRIED_TEMP_KEY = "bambu_last_dried_temp"
  227. BAMBU_LAST_DRIED_HOURS_KEY = "bambu_last_dried_hours"
  228. def _extract_extra_float(extra: dict, key: str) -> float | None:
  229. """Extract a JSON-encoded number from a Spoolman extra dict.
  230. Same storage convention as :func:`_extract_extra_str` — Spoolman keeps
  231. extra values as JSON text, so 250.0 is stored as ``"250.0"``. Returns
  232. ``None`` for missing keys, non-finite values, or anything that does not
  233. decode to a number, so the caller can tell "no baseline recorded" from
  234. "a baseline of zero".
  235. """
  236. raw = extra.get(key)
  237. if raw is None:
  238. return None
  239. if isinstance(raw, (int, float)) and not isinstance(raw, bool):
  240. value = float(raw)
  241. elif isinstance(raw, str):
  242. try:
  243. decoded = json.loads(raw)
  244. except (json.JSONDecodeError, ValueError):
  245. return None
  246. if isinstance(decoded, str):
  247. # Spoolman's extra fields default to field_type "text", which only
  248. # accepts values that decode to a str, so numbers written through
  249. # it arrive as '"263.0"' rather than '263.0'. Both spellings have
  250. # to read back the same, or a baseline written by the reset would
  251. # be invisible to the code that subtracts it.
  252. try:
  253. value = float(decoded)
  254. except ValueError:
  255. return None
  256. elif isinstance(decoded, (int, float)) and not isinstance(decoded, bool):
  257. value = float(decoded)
  258. else:
  259. return None
  260. else:
  261. return None
  262. return value if math.isfinite(value) else None
  263. def parse_spoolman_multi_colors(filament: dict) -> list[str]:
  264. """Spoolman's ``multi_color_hexes`` as a list of bare 6/8-char hex tokens.
  265. Spoolman stores the extra stops of a gradient / dual / multi-colour
  266. filament here, and writes the field as a comma-separated string in some
  267. releases and a list in others -- both shapes are accepted. Tokens keep the
  268. case they arrived in and lose any leading ``#``, which is the form
  269. ``Spool.extra_colors`` stores and ``parseStops`` on the client expects.
  270. Shared with the label renderer rather than parsed twice: the two read the
  271. same field for the same purpose, and a swatch on a printer card that
  272. disagreed with the swatch on the printed label would be worse than either
  273. being wrong on its own.
  274. """
  275. raw = filament.get("multi_color_hexes")
  276. if isinstance(raw, str):
  277. tokens = raw.split(",")
  278. elif isinstance(raw, list):
  279. tokens = [str(token) for token in raw]
  280. else:
  281. return []
  282. return [cleaned for token in tokens if (cleaned := token.strip().lstrip("#"))]
  283. def _last_dried_temp(extra: dict) -> int | None:
  284. value = _extract_extra_float(extra, BAMBU_LAST_DRIED_TEMP_KEY)
  285. return int(round(value)) if value is not None and value > 0 else None
  286. def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
  287. """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
  288. Fields not supported by Spoolman (k_profiles, slicer_filament, …) are
  289. returned as None / empty so the frontend can still render them without
  290. errors. The ``data_origin`` field is set to ``"spoolman"`` so UI code can
  291. distinguish these spools from local ones.
  292. """
  293. raw_id = spool.get("id")
  294. if raw_id is None:
  295. raise ValueError("Spoolman spool is missing required 'id' field")
  296. try:
  297. spool_id: int = int(raw_id)
  298. except (TypeError, ValueError):
  299. raise ValueError(f"Spoolman spool 'id' is not a valid integer: {raw_id!r}")
  300. if spool_id <= 0:
  301. raise ValueError(f"Spoolman spool 'id' must be a positive integer, got {spool_id}")
  302. filament: dict = spool.get("filament") or {}
  303. if not filament:
  304. logger.warning(
  305. "Spoolman spool %s has no filament data — all filament fields will use defaults",
  306. spool_id,
  307. )
  308. vendor: dict = filament.get("vendor") or {}
  309. extra: dict = spool.get("extra") or {}
  310. # RFID tag stored as JSON-encoded string in Spoolman extra.tag.
  311. # 32-char hex → Bambu Lab tray UUID; 8–30-char hex → NFC tag UID.
  312. # Accepting the full realistic UID range (4-byte = 8 chars, 7-byte = 14 chars,
  313. # 10-byte = 20 chars) avoids silently dropping valid SpoolBuddy-written tags.
  314. raw_tag: str = (extra.get("tag") or "").strip('"').upper()
  315. _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
  316. tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
  317. tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
  318. # Spoolman 0.27+ native tags fill whichever of the two extra.tag left empty,
  319. # so a spool linked there shows as tagged here as well.
  320. for native in spool.get("tags") or []:
  321. uid = str(native.get("uid") or "").upper()
  322. if not _TAG_HEX_RE.match(uid):
  323. continue
  324. if len(uid) == 32:
  325. tray_uuid = tray_uuid or uid
  326. elif 8 <= len(uid) <= 30:
  327. tag_uid = tag_uid or uid
  328. # Subtype = filament name with material prefix stripped
  329. material: str = (filament.get("material") or "").strip()
  330. filament_name: str = (filament.get("name") or "").strip()
  331. if material and filament_name.upper().startswith(material.upper()):
  332. subtype: str | None = filament_name[len(material) :].strip() or None
  333. else:
  334. subtype = filament_name or None
  335. # Colour: validate as 6- or 8-char hex; fall back to neutral grey for invalid
  336. # values. An 8-char value already carries its alpha, so appending the opaque
  337. # byte would push it to ten and lose the translucency it was stored to keep.
  338. raw_color = (filament.get("color_hex") or "").upper().removeprefix("#")
  339. color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080"
  340. rgba: str = color_hex if len(color_hex) == 8 else color_hex + "FF"
  341. # Spoolman carries the extra stops but has no concept of a surface effect
  342. # -- its only neighbouring field is `multi_color_direction`, which says how
  343. # the stops are laid out, not that the filament is silk or glitter. So a
  344. # Spoolman spool can render its gradient and never an effect overlay, and
  345. # `effect_type` is pinned to None rather than guessed at.
  346. extra_stops = parse_spoolman_multi_colors(filament)
  347. extra_colors: str | None = ",".join(extra_stops) if extra_stops else None
  348. label_weight: int = _safe_int(spoolman_net_weight(spool), 1000)
  349. real_used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
  350. # Parity with internal mode (#1390): the InventorySpool shape lets the
  351. # frontend compute `remaining = label_weight - weight_used` and
  352. # `consumed = weight_used - weight_used_baseline`. Map Spoolman's two
  353. # independent fields (used_weight, remaining_weight) onto that shape:
  354. # weight_used = label_weight - remaining_weight (so remaining matches)
  355. # baseline = weight_used - used_weight (so consumed matches)
  356. # When remaining_weight is unset (legacy spools, or filament linked but
  357. # never primed), fall back to the old behaviour: weight_used =
  358. # used_weight, baseline = 0.
  359. #
  360. # A "Reset usage to 0" records the then-current used_weight under
  361. # BAMBU_WEIGHT_USED_BASELINE_KEY in spool.extra and touches nothing else,
  362. # so displayed consumed is used_weight minus that. It is folded into the
  363. # baseline here because the frontend's arithmetic is fixed: it always shows
  364. # `weight_used - weight_used_baseline`. The reset used to PATCH Spoolman's
  365. # used_weight to 0 instead, and Spoolman recomputes remaining from initial
  366. # minus used, so the spool jumped back to full (#2906).
  367. # `or 0.0` would collapse None and 0.0, which is the one distinction
  368. # _extract_extra_float exists to preserve; spell the absent case out.
  369. # Clamp the low end too: the write side already refuses a negative
  370. # baseline, and without the same rule here a negative value stored by hand
  371. # would be subtracted, inflating the displayed counter. min() below only
  372. # bounds the other end.
  373. stored = _extract_extra_float(extra, BAMBU_WEIGHT_USED_BASELINE_KEY)
  374. stored_baseline = 0.0 if stored is None else max(0.0, stored)
  375. remaining_raw = spool.get("remaining_weight")
  376. if remaining_raw is not None:
  377. remaining_weight: float = _safe_float(remaining_raw, 0.0)
  378. used_weight: float = max(0.0, float(label_weight) - remaining_weight)
  379. weight_used_baseline: float = max(0.0, used_weight - real_used_weight + stored_baseline)
  380. else:
  381. used_weight = real_used_weight
  382. weight_used_baseline = stored_baseline # already clamped to >= 0 above
  383. # Never let the displayed counter read negative. used_weight can fall below
  384. # the baseline after a reset without anyone touching it: the AMS sync
  385. # writes remaining_weight from the tray percentage, and Spoolman derives
  386. # used_weight from that. The counter then reads 0 until consumption climbs
  387. # past the baseline again. Internal mode behaves the same way, and it is
  388. # far better than before #2906, when the next sync undid the reset outright.
  389. weight_used_baseline = min(weight_used_baseline, used_weight)
  390. # Archived state – Spoolman uses a boolean ``archived`` field
  391. archived: bool = spool.get("archived", False)
  392. archived_at: str | None = None
  393. if archived:
  394. archived_at = spool.get("last_used") or spool.get("registered") or None
  395. created_at: str | None = spool.get("registered") or None
  396. # Spoolman has no `color_name` field on Filament — confirmed against the
  397. # FilamentUpdateParameters schema in 0.23.1: name/vendor_id/material/price/
  398. # density/diameter/weight/spool_weight/article_number/comment/extruder_temp/
  399. # bed_temp/color_hex/multi_color_hexes/multi_color_direction/external_id/
  400. # extra, no color_name (#1357). The previous attempt (b8e350c3) was
  401. # PATCHing a key Spoolman silently discards, which is why color_name
  402. # never actually persisted from the user's edits.
  403. #
  404. # We persist it ourselves under spool.extra.bambu_color_name (JSON-encoded
  405. # string, same pattern as bambu_slicer_filament). Read order:
  406. # 1. spool.extra.bambu_color_name (the canonical store)
  407. # 2. filament.color_name (forward-compat — picks up the value if a
  408. # future Spoolman release adds the field, or if an admin populated
  409. # it via a custom extra-field they registered themselves)
  410. # 3. subtype (synth fallback so the inventory list isn't a sea of
  411. # "Unknown color" entries on installs with neither field set)
  412. #
  413. # color_name_is_synthesized = True only when we fell back to subtype.
  414. # The edit form uses it to leave the input blank, so the user doesn't
  415. # round-trip the synth value back as if they had set it.
  416. extra_color_name = _extract_extra_str(extra, "bambu_color_name") or None
  417. stored_color_name = extra_color_name or (filament.get("color_name") or None)
  418. color_name: str | None = stored_color_name or subtype or None
  419. color_name_is_synthesized: bool = stored_color_name is None and color_name is not None
  420. nozzle_temp_raw = filament.get("settings_extruder_temp")
  421. nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None
  422. return {
  423. "id": spool_id,
  424. "material": material,
  425. "subtype": subtype,
  426. "color_name": color_name,
  427. "color_name_is_synthesized": color_name_is_synthesized,
  428. "rgba": rgba,
  429. "extra_colors": extra_colors,
  430. "effect_type": None,
  431. "brand": vendor.get("name") or None,
  432. "label_weight": label_weight,
  433. "core_weight": _safe_int(spoolman_tare(spool)[0], 250),
  434. # True when the spool has no spool_weight of its own and core_weight is
  435. # the filament type's, the vendor's or the 250 g fallback. The spool form needs it
  436. # to copy a spool without dropping an own tare or stamping an
  437. # inherited one (#2908).
  438. "core_weight_is_inherited": spool.get("spool_weight") is None,
  439. "core_weight_catalog_id": None,
  440. "weight_used": used_weight,
  441. "weight_used_baseline": weight_used_baseline,
  442. "weight_locked": False,
  443. "last_scale_weight": None,
  444. "last_weighed_at": None,
  445. # BambuStudio slicer preset — Spoolman has no native field, so the
  446. # update endpoint persists these under bambu_slicer_filament[_name]
  447. # in the spool's extra dict. Values are JSON-encoded strings; an
  448. # empty string ("") means cleared. Falls back to Spoolman's
  449. # filament_name for slicer_filament_name when nothing is stored.
  450. "slicer_filament": (_extract_extra_str(extra, "bambu_slicer_filament") or None),
  451. "slicer_filament_name": (_extract_extra_str(extra, "bambu_slicer_filament_name") or (filament_name or None)),
  452. "nozzle_temp_min": nozzle_temp_min,
  453. "nozzle_temp_max": None,
  454. "note": spool.get("comment") or None,
  455. "added_full": None,
  456. "last_used": spool.get("last_used"),
  457. "last_dried_at": _extract_extra_str(extra, BAMBU_LAST_DRIED_AT_KEY) or None,
  458. "last_dried_temp": _last_dried_temp(extra),
  459. "last_dried_hours": _extract_extra_float(extra, BAMBU_LAST_DRIED_HOURS_KEY),
  460. # encode_time semantics differ: local records NFC write time; Spoolman first_used
  461. # records first print use — different events; using first_used as best available proxy.
  462. "encode_time": spool.get("first_used"),
  463. "tag_uid": tag_uid,
  464. "tray_uuid": tray_uuid,
  465. "data_origin": "spoolman",
  466. "tag_type": "spoolman",
  467. "archived_at": archived_at,
  468. "created_at": created_at,
  469. # Spoolman has no updated_at field; use registered timestamp as best available proxy
  470. "updated_at": created_at,
  471. # Spoolman's spool price is what the whole spool cost (#3194).
  472. "cost_per_kg": spoolman_cost_per_kg(spool),
  473. "effective_cost_per_kg": _charged_cost_per_kg(spool),
  474. # Spoolman's filament.article_number maps 1:1 onto the internal
  475. # material number (#2870): both identify the purchasable product.
  476. # Trimmed for the same reason the schema validator trims the internal
  477. # one — the filter chip builds its options from trimmed values and
  478. # matches exactly, so a padded number would list and match nothing.
  479. "material_number": ((filament.get("article_number") or "").strip() or None),
  480. "storage_location": spool.get("location") or None,
  481. "location_id": None,
  482. "k_profiles": [],
  483. }