_spoolman_helpers.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  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. label_weight: int | None
  23. core_weight: int | None
  24. core_weight_catalog_id: None
  25. weight_used: float | None
  26. weight_used_baseline: float | None
  27. weight_locked: bool
  28. last_scale_weight: None
  29. last_weighed_at: None
  30. slicer_filament: None
  31. slicer_filament_name: str | None
  32. nozzle_temp_min: int | None
  33. nozzle_temp_max: None
  34. note: str | None
  35. added_full: None
  36. last_used: str | None
  37. encode_time: str | None
  38. tag_uid: str | None
  39. tray_uuid: str | None
  40. data_origin: str | None
  41. tag_type: str | None
  42. archived_at: str | None
  43. created_at: str | None # None when Spoolman spool has no registered timestamp
  44. updated_at: str | None
  45. cost_per_kg: float | None
  46. storage_location: str | None
  47. location_id: int | None
  48. k_profiles: list[Any]
  49. class NormalizedVendorRef(TypedDict):
  50. """Vendor reference embedded in a NormalizedFilament."""
  51. id: int
  52. name: str
  53. class NormalizedFilament(TypedDict):
  54. """Normalised Spoolman filament dict returned by the /filaments catalog endpoint."""
  55. id: int
  56. name: str
  57. material: str | None
  58. color_hex: str | None
  59. color_name: str | None
  60. weight: int | None
  61. spool_weight: float | None
  62. vendor: NormalizedVendorRef | None
  63. def assert_safe_spoolman_url(url: str) -> None:
  64. """Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
  65. Thin wrapper over the shared LAN-service policy — see
  66. ``_url_safety.assert_safe_lan_service_url`` for what is and isn't
  67. rejected, and why loopback/RFC-1918 are deliberately permitted (running
  68. Spoolman on the same host or home LAN is THE normal topology).
  69. Kept as a named function because the "Spoolman URL …" wording in its
  70. errors is user-facing and asserted by existing tests.
  71. """
  72. assert_safe_lan_service_url(url, label="Spoolman URL")
  73. _COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
  74. _TAG_HEX_RE = re.compile(r"^[0-9A-F]+$")
  75. def _safe_int(value: object, fallback: int) -> int:
  76. """Convert value to int, returning fallback for None/NaN/Inf/non-numeric."""
  77. try:
  78. f = float(value) # type: ignore[arg-type]
  79. if math.isfinite(f):
  80. return int(f)
  81. except (TypeError, ValueError):
  82. pass
  83. return fallback
  84. def _safe_float(value: object, fallback: float) -> float:
  85. """Convert value to float, returning fallback for None/NaN/Inf/non-numeric."""
  86. try:
  87. f = float(value) # type: ignore[arg-type]
  88. if math.isfinite(f):
  89. return f
  90. except (TypeError, ValueError):
  91. pass
  92. return fallback
  93. def _safe_optional_float(value: object) -> float | None:
  94. """Convert value to finite float, or None if missing/NaN/Infinite/non-numeric.
  95. Used for optional monetary fields (price) to prevent Infinity/NaN from
  96. reaching JSON serialisation, which raises ValueError with allow_nan=False.
  97. """
  98. if value is None:
  99. return None
  100. try:
  101. f = float(value) # type: ignore[arg-type]
  102. if math.isfinite(f):
  103. return f
  104. except (TypeError, ValueError):
  105. pass
  106. return None
  107. def _extract_extra_str(extra: dict, key: str) -> str:
  108. """Extract a JSON-encoded string from a Spoolman extra dict.
  109. Spoolman stores extra values as JSON-stringified text — a stored string
  110. "GFL05" appears as `'"GFL05"'` (six chars including the quotes). This
  111. unwraps that, returning the bare string. Returns "" for missing keys,
  112. non-strings, or invalid JSON.
  113. """
  114. raw = extra.get(key)
  115. if not isinstance(raw, str):
  116. return ""
  117. try:
  118. decoded = json.loads(raw)
  119. except (json.JSONDecodeError, ValueError):
  120. # Tolerate bare-string values written without JSON encoding.
  121. return raw
  122. return decoded if isinstance(decoded, str) else ""
  123. def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
  124. """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
  125. Fields not supported by Spoolman (k_profiles, slicer_filament, …) are
  126. returned as None / empty so the frontend can still render them without
  127. errors. The ``data_origin`` field is set to ``"spoolman"`` so UI code can
  128. distinguish these spools from local ones.
  129. """
  130. raw_id = spool.get("id")
  131. if raw_id is None:
  132. raise ValueError("Spoolman spool is missing required 'id' field")
  133. try:
  134. spool_id: int = int(raw_id)
  135. except (TypeError, ValueError):
  136. raise ValueError(f"Spoolman spool 'id' is not a valid integer: {raw_id!r}")
  137. if spool_id <= 0:
  138. raise ValueError(f"Spoolman spool 'id' must be a positive integer, got {spool_id}")
  139. filament: dict = spool.get("filament") or {}
  140. if not filament:
  141. logger.warning(
  142. "Spoolman spool %s has no filament data — all filament fields will use defaults",
  143. spool_id,
  144. )
  145. vendor: dict = filament.get("vendor") or {}
  146. extra: dict = spool.get("extra") or {}
  147. # RFID tag stored as JSON-encoded string in Spoolman extra.tag.
  148. # 32-char hex → Bambu Lab tray UUID; 8–30-char hex → NFC tag UID.
  149. # Accepting the full realistic UID range (4-byte = 8 chars, 7-byte = 14 chars,
  150. # 10-byte = 20 chars) avoids silently dropping valid SpoolBuddy-written tags.
  151. raw_tag: str = (extra.get("tag") or "").strip('"').upper()
  152. _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
  153. tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
  154. tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
  155. # Subtype = filament name with material prefix stripped
  156. material: str = (filament.get("material") or "").strip()
  157. filament_name: str = (filament.get("name") or "").strip()
  158. if material and filament_name.upper().startswith(material.upper()):
  159. subtype: str | None = filament_name[len(material) :].strip() or None
  160. else:
  161. subtype = filament_name or None
  162. # Colour: validate as 6-char hex; fall back to neutral grey for invalid values
  163. raw_color = (filament.get("color_hex") or "").upper().removeprefix("#")
  164. color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080"
  165. rgba: str = color_hex + "FF"
  166. label_weight: int = _safe_int(filament.get("weight"), 1000)
  167. real_used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
  168. # Parity with internal mode (#1390): the InventorySpool shape lets the
  169. # frontend compute `remaining = label_weight - weight_used` and
  170. # `consumed = weight_used - weight_used_baseline`. Map Spoolman's two
  171. # independent fields (used_weight, remaining_weight) onto that shape:
  172. # weight_used = label_weight - remaining_weight (so remaining matches)
  173. # baseline = weight_used - used_weight (so consumed matches)
  174. # When remaining_weight is unset (legacy spools, or filament linked but
  175. # never primed), fall back to the old behaviour: weight_used =
  176. # used_weight, baseline = 0.
  177. remaining_raw = spool.get("remaining_weight")
  178. if remaining_raw is not None:
  179. remaining_weight: float = _safe_float(remaining_raw, 0.0)
  180. used_weight: float = max(0.0, float(label_weight) - remaining_weight)
  181. weight_used_baseline: float = max(0.0, used_weight - real_used_weight)
  182. else:
  183. used_weight = real_used_weight
  184. weight_used_baseline = 0.0
  185. # Archived state – Spoolman uses a boolean ``archived`` field
  186. archived: bool = spool.get("archived", False)
  187. archived_at: str | None = None
  188. if archived:
  189. archived_at = spool.get("last_used") or spool.get("registered") or None
  190. created_at: str | None = spool.get("registered") or None
  191. # Spoolman has no `color_name` field on Filament — confirmed against the
  192. # FilamentUpdateParameters schema in 0.23.1: name/vendor_id/material/price/
  193. # density/diameter/weight/spool_weight/article_number/comment/extruder_temp/
  194. # bed_temp/color_hex/multi_color_hexes/multi_color_direction/external_id/
  195. # extra, no color_name (#1357). The previous attempt (b8e350c3) was
  196. # PATCHing a key Spoolman silently discards, which is why color_name
  197. # never actually persisted from the user's edits.
  198. #
  199. # We persist it ourselves under spool.extra.bambu_color_name (JSON-encoded
  200. # string, same pattern as bambu_slicer_filament). Read order:
  201. # 1. spool.extra.bambu_color_name (the canonical store)
  202. # 2. filament.color_name (forward-compat — picks up the value if a
  203. # future Spoolman release adds the field, or if an admin populated
  204. # it via a custom extra-field they registered themselves)
  205. # 3. subtype (synth fallback so the inventory list isn't a sea of
  206. # "Unknown color" entries on installs with neither field set)
  207. #
  208. # color_name_is_synthesized = True only when we fell back to subtype.
  209. # The edit form uses it to leave the input blank, so the user doesn't
  210. # round-trip the synth value back as if they had set it.
  211. extra_color_name = _extract_extra_str(extra, "bambu_color_name") or None
  212. stored_color_name = extra_color_name or (filament.get("color_name") or None)
  213. color_name: str | None = stored_color_name or subtype or None
  214. color_name_is_synthesized: bool = stored_color_name is None and color_name is not None
  215. nozzle_temp_raw = filament.get("settings_extruder_temp")
  216. nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None
  217. return {
  218. "id": spool_id,
  219. "material": material,
  220. "subtype": subtype,
  221. "color_name": color_name,
  222. "color_name_is_synthesized": color_name_is_synthesized,
  223. "rgba": rgba,
  224. "brand": vendor.get("name") or None,
  225. "label_weight": label_weight,
  226. "core_weight": _safe_int(
  227. spool.get("spool_weight") if spool.get("spool_weight") is not None else filament.get("spool_weight"), 250
  228. ),
  229. "core_weight_catalog_id": None,
  230. "weight_used": used_weight,
  231. "weight_used_baseline": weight_used_baseline,
  232. "weight_locked": False,
  233. "last_scale_weight": None,
  234. "last_weighed_at": None,
  235. # BambuStudio slicer preset — Spoolman has no native field, so the
  236. # update endpoint persists these under bambu_slicer_filament[_name]
  237. # in the spool's extra dict. Values are JSON-encoded strings; an
  238. # empty string ("") means cleared. Falls back to Spoolman's
  239. # filament_name for slicer_filament_name when nothing is stored.
  240. "slicer_filament": (_extract_extra_str(extra, "bambu_slicer_filament") or None),
  241. "slicer_filament_name": (_extract_extra_str(extra, "bambu_slicer_filament_name") or (filament_name or None)),
  242. "nozzle_temp_min": nozzle_temp_min,
  243. "nozzle_temp_max": None,
  244. "note": spool.get("comment") or None,
  245. "added_full": None,
  246. "last_used": spool.get("last_used"),
  247. # encode_time semantics differ: local records NFC write time; Spoolman first_used
  248. # records first print use — different events; using first_used as best available proxy.
  249. "encode_time": spool.get("first_used"),
  250. "tag_uid": tag_uid,
  251. "tray_uuid": tray_uuid,
  252. "data_origin": "spoolman",
  253. "tag_type": "spoolman",
  254. "archived_at": archived_at,
  255. "created_at": created_at,
  256. # Spoolman has no updated_at field; use registered timestamp as best available proxy
  257. "updated_at": created_at,
  258. "cost_per_kg": _safe_optional_float(spool.get("price")),
  259. "storage_location": spool.get("location") or None,
  260. "location_id": None,
  261. "k_profiles": [],
  262. }