_spoolman_helpers.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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 ipaddress
  6. import json
  7. import logging
  8. import math
  9. import re
  10. from typing import Any
  11. from urllib.parse import urlparse
  12. from typing_extensions import TypedDict
  13. from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
  14. logger = logging.getLogger(__name__)
  15. class MappedSpoolFields(TypedDict):
  16. """Full shape of the dict returned by _map_spoolman_spool (InventorySpool-compatible)."""
  17. id: int
  18. material: str | None
  19. subtype: str | None
  20. brand: str | None
  21. color_name: str | None
  22. color_name_is_synthesized: bool
  23. rgba: str | None
  24. label_weight: int | None
  25. core_weight: int | None
  26. core_weight_catalog_id: None
  27. weight_used: float | None
  28. weight_used_baseline: float | None
  29. weight_locked: bool
  30. last_scale_weight: None
  31. last_weighed_at: None
  32. slicer_filament: None
  33. slicer_filament_name: str | None
  34. nozzle_temp_min: int | None
  35. nozzle_temp_max: None
  36. note: str | None
  37. added_full: None
  38. last_used: str | None
  39. encode_time: str | None
  40. tag_uid: str | None
  41. tray_uuid: str | None
  42. data_origin: str | None
  43. tag_type: str | None
  44. archived_at: str | None
  45. created_at: str | None # None when Spoolman spool has no registered timestamp
  46. updated_at: str | None
  47. cost_per_kg: float | None
  48. storage_location: str | None
  49. k_profiles: list[Any]
  50. class NormalizedVendorRef(TypedDict):
  51. """Vendor reference embedded in a NormalizedFilament."""
  52. id: int
  53. name: str
  54. class NormalizedFilament(TypedDict):
  55. """Normalised Spoolman filament dict returned by the /filaments catalog endpoint."""
  56. id: int
  57. name: str
  58. material: str | None
  59. color_hex: str | None
  60. color_name: str | None
  61. weight: int | None
  62. spool_weight: float | None
  63. vendor: NormalizedVendorRef | None
  64. def assert_safe_spoolman_url(url: str) -> None:
  65. """Raise ValueError if *url* should be blocked as an SSRF risk.
  66. Bambuddy is typically deployed on a home LAN alongside Spoolman, so
  67. loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
  68. 172.16-31.x) must be permitted — they are THE normal Spoolman topology.
  69. This guard therefore targets the genuinely dangerous cases only.
  70. Checks performed:
  71. - Scheme must be http or https (no file://, gopher://, dict://, etc.).
  72. - Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
  73. (e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
  74. ``ValueError`` for these forms so they would otherwise bypass the
  75. explicit-IP block below, but libc (and browsers) resolve them as valid
  76. IPv4 addresses.
  77. - Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
  78. fd00:ec2::254) are blocked — the classic SSRF credential-exfil target.
  79. - Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
  80. are blocked — pointless as a destination and suggests misuse.
  81. - IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
  82. bypass the checks above.
  83. Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
  84. are out of scope — DNS resolution is deliberately not performed here.
  85. """
  86. parsed = urlparse(url)
  87. if parsed.scheme.lower() not in ("http", "https"):
  88. raise ValueError("Spoolman URL must use http or https")
  89. hostname = (parsed.hostname or "").lower()
  90. # Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
  91. # http://0x7f000001/). These slip past ipaddress.ip_address() but libc
  92. # (and browsers) parse them as IPv4 — an obvious bypass if not caught.
  93. if NUMERIC_IP_RE.match(hostname):
  94. raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
  95. try:
  96. addr = ipaddress.ip_address(hostname)
  97. except ValueError:
  98. # Not a bare IP address — includes intentional cases such as "localhost" and
  99. # RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
  100. # a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
  101. # Running Spoolman on the same host or home LAN is the standard Bambuddy
  102. # topology, so loopback and private ranges are deliberately NOT blocked here.
  103. return
  104. # Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
  105. # encode a blocked IPv4 into an IPv6 literal to bypass the check.
  106. effective = unwrap_ipv4_mapped(addr)
  107. if effective in CLOUD_METADATA_IPS:
  108. raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
  109. if effective.is_multicast or effective.is_unspecified:
  110. raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
  111. _COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
  112. _TAG_HEX_RE = re.compile(r"^[0-9A-F]+$")
  113. def _safe_int(value: object, fallback: int) -> int:
  114. """Convert value to int, returning fallback for None/NaN/Inf/non-numeric."""
  115. try:
  116. f = float(value) # type: ignore[arg-type]
  117. if math.isfinite(f):
  118. return int(f)
  119. except (TypeError, ValueError):
  120. pass
  121. return fallback
  122. def _safe_float(value: object, fallback: float) -> float:
  123. """Convert value to float, returning fallback for None/NaN/Inf/non-numeric."""
  124. try:
  125. f = float(value) # type: ignore[arg-type]
  126. if math.isfinite(f):
  127. return f
  128. except (TypeError, ValueError):
  129. pass
  130. return fallback
  131. def _safe_optional_float(value: object) -> float | None:
  132. """Convert value to finite float, or None if missing/NaN/Infinite/non-numeric.
  133. Used for optional monetary fields (price) to prevent Infinity/NaN from
  134. reaching JSON serialisation, which raises ValueError with allow_nan=False.
  135. """
  136. if value is None:
  137. return None
  138. try:
  139. f = float(value) # type: ignore[arg-type]
  140. if math.isfinite(f):
  141. return f
  142. except (TypeError, ValueError):
  143. pass
  144. return None
  145. def _extract_extra_str(extra: dict, key: str) -> str:
  146. """Extract a JSON-encoded string from a Spoolman extra dict.
  147. Spoolman stores extra values as JSON-stringified text — a stored string
  148. "GFL05" appears as `'"GFL05"'` (six chars including the quotes). This
  149. unwraps that, returning the bare string. Returns "" for missing keys,
  150. non-strings, or invalid JSON.
  151. """
  152. raw = extra.get(key)
  153. if not isinstance(raw, str):
  154. return ""
  155. try:
  156. decoded = json.loads(raw)
  157. except (json.JSONDecodeError, ValueError):
  158. # Tolerate bare-string values written without JSON encoding.
  159. return raw
  160. return decoded if isinstance(decoded, str) else ""
  161. def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
  162. """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
  163. Fields not supported by Spoolman (k_profiles, slicer_filament, …) are
  164. returned as None / empty so the frontend can still render them without
  165. errors. The ``data_origin`` field is set to ``"spoolman"`` so UI code can
  166. distinguish these spools from local ones.
  167. """
  168. raw_id = spool.get("id")
  169. if raw_id is None:
  170. raise ValueError("Spoolman spool is missing required 'id' field")
  171. try:
  172. spool_id: int = int(raw_id)
  173. except (TypeError, ValueError):
  174. raise ValueError(f"Spoolman spool 'id' is not a valid integer: {raw_id!r}")
  175. if spool_id <= 0:
  176. raise ValueError(f"Spoolman spool 'id' must be a positive integer, got {spool_id}")
  177. filament: dict = spool.get("filament") or {}
  178. if not filament:
  179. logger.warning(
  180. "Spoolman spool %s has no filament data — all filament fields will use defaults",
  181. spool_id,
  182. )
  183. vendor: dict = filament.get("vendor") or {}
  184. extra: dict = spool.get("extra") or {}
  185. # RFID tag stored as JSON-encoded string in Spoolman extra.tag.
  186. # 32-char hex → Bambu Lab tray UUID; 8–30-char hex → NFC tag UID.
  187. # Accepting the full realistic UID range (4-byte = 8 chars, 7-byte = 14 chars,
  188. # 10-byte = 20 chars) avoids silently dropping valid SpoolBuddy-written tags.
  189. raw_tag: str = (extra.get("tag") or "").strip('"').upper()
  190. _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
  191. tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
  192. tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
  193. # Subtype = filament name with material prefix stripped
  194. material: str = (filament.get("material") or "").strip()
  195. filament_name: str = (filament.get("name") or "").strip()
  196. if material and filament_name.upper().startswith(material.upper()):
  197. subtype: str | None = filament_name[len(material) :].strip() or None
  198. else:
  199. subtype = filament_name or None
  200. # Colour: validate as 6-char hex; fall back to neutral grey for invalid values
  201. raw_color = (filament.get("color_hex") or "").upper().removeprefix("#")
  202. color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080"
  203. rgba: str = color_hex + "FF"
  204. label_weight: int = _safe_int(filament.get("weight"), 1000)
  205. real_used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
  206. # Parity with internal mode (#1390): the InventorySpool shape lets the
  207. # frontend compute `remaining = label_weight - weight_used` and
  208. # `consumed = weight_used - weight_used_baseline`. Map Spoolman's two
  209. # independent fields (used_weight, remaining_weight) onto that shape:
  210. # weight_used = label_weight - remaining_weight (so remaining matches)
  211. # baseline = weight_used - used_weight (so consumed matches)
  212. # When remaining_weight is unset (legacy spools, or filament linked but
  213. # never primed), fall back to the old behaviour: weight_used =
  214. # used_weight, baseline = 0.
  215. remaining_raw = spool.get("remaining_weight")
  216. if remaining_raw is not None:
  217. remaining_weight: float = _safe_float(remaining_raw, 0.0)
  218. used_weight: float = max(0.0, float(label_weight) - remaining_weight)
  219. weight_used_baseline: float = max(0.0, used_weight - real_used_weight)
  220. else:
  221. used_weight = real_used_weight
  222. weight_used_baseline = 0.0
  223. # Archived state – Spoolman uses a boolean ``archived`` field
  224. archived: bool = spool.get("archived", False)
  225. archived_at: str | None = None
  226. if archived:
  227. archived_at = spool.get("last_used") or spool.get("registered") or None
  228. created_at: str | None = spool.get("registered") or None
  229. # Spoolman has no `color_name` field on Filament — confirmed against the
  230. # FilamentUpdateParameters schema in 0.23.1: name/vendor_id/material/price/
  231. # density/diameter/weight/spool_weight/article_number/comment/extruder_temp/
  232. # bed_temp/color_hex/multi_color_hexes/multi_color_direction/external_id/
  233. # extra, no color_name (#1357). The previous attempt (b8e350c3) was
  234. # PATCHing a key Spoolman silently discards, which is why color_name
  235. # never actually persisted from the user's edits.
  236. #
  237. # We persist it ourselves under spool.extra.bambu_color_name (JSON-encoded
  238. # string, same pattern as bambu_slicer_filament). Read order:
  239. # 1. spool.extra.bambu_color_name (the canonical store)
  240. # 2. filament.color_name (forward-compat — picks up the value if a
  241. # future Spoolman release adds the field, or if an admin populated
  242. # it via a custom extra-field they registered themselves)
  243. # 3. subtype (synth fallback so the inventory list isn't a sea of
  244. # "Unknown color" entries on installs with neither field set)
  245. #
  246. # color_name_is_synthesized = True only when we fell back to subtype.
  247. # The edit form uses it to leave the input blank, so the user doesn't
  248. # round-trip the synth value back as if they had set it.
  249. extra_color_name = _extract_extra_str(extra, "bambu_color_name") or None
  250. stored_color_name = extra_color_name or (filament.get("color_name") or None)
  251. color_name: str | None = stored_color_name or subtype or None
  252. color_name_is_synthesized: bool = stored_color_name is None and color_name is not None
  253. nozzle_temp_raw = filament.get("settings_extruder_temp")
  254. nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None
  255. return {
  256. "id": spool_id,
  257. "material": material,
  258. "subtype": subtype,
  259. "color_name": color_name,
  260. "color_name_is_synthesized": color_name_is_synthesized,
  261. "rgba": rgba,
  262. "brand": vendor.get("name") or None,
  263. "label_weight": label_weight,
  264. "core_weight": _safe_int(
  265. spool.get("spool_weight") if spool.get("spool_weight") is not None else filament.get("spool_weight"), 250
  266. ),
  267. "core_weight_catalog_id": None,
  268. "weight_used": used_weight,
  269. "weight_used_baseline": weight_used_baseline,
  270. "weight_locked": False,
  271. "last_scale_weight": None,
  272. "last_weighed_at": None,
  273. # BambuStudio slicer preset — Spoolman has no native field, so the
  274. # update endpoint persists these under bambu_slicer_filament[_name]
  275. # in the spool's extra dict. Values are JSON-encoded strings; an
  276. # empty string ("") means cleared. Falls back to Spoolman's
  277. # filament_name for slicer_filament_name when nothing is stored.
  278. "slicer_filament": (_extract_extra_str(extra, "bambu_slicer_filament") or None),
  279. "slicer_filament_name": (_extract_extra_str(extra, "bambu_slicer_filament_name") or (filament_name or None)),
  280. "nozzle_temp_min": nozzle_temp_min,
  281. "nozzle_temp_max": None,
  282. "note": spool.get("comment") or None,
  283. "added_full": None,
  284. "last_used": spool.get("last_used"),
  285. # encode_time semantics differ: local records NFC write time; Spoolman first_used
  286. # records first print use — different events; using first_used as best available proxy.
  287. "encode_time": spool.get("first_used"),
  288. "tag_uid": tag_uid,
  289. "tray_uuid": tray_uuid,
  290. "data_origin": "spoolman",
  291. "tag_type": "spoolman",
  292. "archived_at": archived_at,
  293. "created_at": created_at,
  294. # Spoolman has no updated_at field; use registered timestamp as best available proxy
  295. "updated_at": created_at,
  296. "cost_per_kg": _safe_optional_float(spool.get("price")),
  297. "storage_location": spool.get("location") or None,
  298. "k_profiles": [],
  299. }