Browse Source

Revert "feat(inventory): unified Spoolman inventory UI + AMS slot assignments…" (#1232)

This reverts commit 55d71498e990f3af59218816f5d1ab45143f3615.
MartinNYHC 3 weeks ago
parent
commit
dac2a31192
100 changed files with 1363 additions and 22564 deletions
  1. 1 1
      CHANGELOG.md
  2. 0 318
      backend/app/api/routes/_spoolman_helpers.py
  3. 16 51
      backend/app/api/routes/inventory.py
  4. 64 337
      backend/app/api/routes/printers.py
  5. 80 94
      backend/app/api/routes/settings.py
  6. 88 433
      backend/app/api/routes/spoolbuddy.py
  7. 109 413
      backend/app/api/routes/spoolman.py
  8. 0 1541
      backend/app/api/routes/spoolman_inventory.py
  9. 2 51
      backend/app/core/auth.py
  10. 37 146
      backend/app/core/database.py
  11. 22 169
      backend/app/main.py
  12. 1 3
      backend/app/models/spool.py
  13. 0 1
      backend/app/models/spoolbuddy_device.py
  14. 0 34
      backend/app/models/spoolman_k_profile.py
  15. 0 35
      backend/app/models/spoolman_slot_assignment.py
  16. 32 48
      backend/app/schemas/spoolbuddy.py
  17. 0 30
      backend/app/schemas/spoolman.py
  18. 23 57
      backend/app/services/opentag3d.py
  19. 0 21
      backend/app/services/spool_tag_matcher.py
  20. 22 89
      backend/app/services/spoolbuddy_ssh.py
  21. 312 444
      backend/app/services/spoolman.py
  22. 5 21
      backend/app/services/spoolman_tracking.py
  23. 1 32
      backend/app/utils/filament_ids.py
  24. 0 2
      backend/tests/conftest.py
  25. 0 161
      backend/tests/integration/test_auth_apikey_rbac.py
  26. 0 104
      backend/tests/integration/test_inventory_assign.py
  27. 1 1272
      backend/tests/integration/test_printers_api.py
  28. 0 184
      backend/tests/integration/test_settings_api_key_scrubbing.py
  29. 2 1178
      backend/tests/integration/test_spoolbuddy.py
  30. 0 387
      backend/tests/integration/test_spoolbuddy_spoolman_nfc.py
  31. 0 365
      backend/tests/integration/test_spoolman_ams_sync.py
  32. 13 670
      backend/tests/integration/test_spoolman_api.py
  33. 0 527
      backend/tests/integration/test_spoolman_filament_patch.py
  34. 0 2526
      backend/tests/integration/test_spoolman_inventory_api.py
  35. 0 279
      backend/tests/integration/test_spoolman_k_profiles.py
  36. 0 863
      backend/tests/integration/test_spoolman_slot_assignment_mqtt.py
  37. 0 549
      backend/tests/integration/test_spoolman_slot_assignments.py
  38. 0 144
      backend/tests/integration/test_spoolman_slot_concurrency.py
  39. 0 126
      backend/tests/unit/services/test_spool_tag_matcher.py
  40. 23 228
      backend/tests/unit/services/test_spoolbuddy_ssh.py
  41. 17 244
      backend/tests/unit/services/test_spoolman_service.py
  42. 3 7
      backend/tests/unit/services/test_spoolman_tracking.py
  43. 0 152
      backend/tests/unit/test_db_dialect.py
  44. 0 179
      backend/tests/unit/test_opentag3d.py
  45. 0 207
      backend/tests/unit/test_spoolbuddy_schema_validation.py
  46. 0 58
      backend/tests/unit/test_spoolman_extra_lock.py
  47. 0 433
      backend/tests/unit/test_spoolman_inventory_helpers.py
  48. 0 424
      backend/tests/unit/test_spoolman_inventory_methods.py
  49. 0 77
      backend/tests/unit/test_spoolman_slot_ddl.py
  50. 0 223
      backend/tests/unit/test_spoolman_tracking.py
  51. 0 96
      backend/tests/unit/test_ssrf_guard.py
  52. 0 383
      docs/spoolman-inventory-test-plan.md
  53. 0 45
      frontend/src/__tests__/components/AdditionalSection.test.tsx
  54. 0 71
      frontend/src/__tests__/components/AssignSpoolModal.test.tsx
  55. 0 251
      frontend/src/__tests__/components/AssignToAmsModal.test.tsx
  56. 30 137
      frontend/src/__tests__/components/FilamentHoverCard.test.tsx
  57. 0 122
      frontend/src/__tests__/components/InventorySpoolInfoCard.test.tsx
  58. 0 473
      frontend/src/__tests__/components/SpoolCatalogSettings.test.tsx
  59. 0 460
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  60. 0 49
      frontend/src/__tests__/components/SpoolInfoCard.test.tsx
  61. 0 97
      frontend/src/__tests__/components/SpoolWeightUpdateModal.test.tsx
  62. 0 188
      frontend/src/__tests__/components/SpoolmanFilamentPicker.test.tsx
  63. 0 48
      frontend/src/__tests__/components/SpoolmanSettings.test.tsx
  64. 0 81
      frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts
  65. 0 268
      frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx
  66. 0 165
      frontend/src/__tests__/pages/InventoryPageSearch.test.ts
  67. 0 300
      frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx
  68. 0 317
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  69. 0 447
      frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx
  70. 7 349
      frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx
  71. 7 56
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
  72. 0 110
      frontend/src/__tests__/utils/inventorySearch.test.ts
  73. 0 65
      frontend/src/__tests__/utils/isBambuLabSpool.test.ts
  74. 0 96
      frontend/src/__tests__/utils/spoolFormValidation.test.ts
  75. 2 127
      frontend/src/api/client.ts
  76. 17 117
      frontend/src/components/AssignSpoolModal.tsx
  77. 77 70
      frontend/src/components/FilamentHoverCard.tsx
  78. 0 1
      frontend/src/components/LinkSpoolModal.tsx
  79. 199 456
      frontend/src/components/SpoolCatalogSettings.tsx
  80. 51 205
      frontend/src/components/SpoolFormModal.tsx
  81. 0 102
      frontend/src/components/SpoolWeightUpdateModal.tsx
  82. 9 80
      frontend/src/components/SpoolmanSettings.tsx
  83. 8 26
      frontend/src/components/spool-form/AdditionalSection.tsx
  84. 0 168
      frontend/src/components/spool-form/SpoolmanFilamentPicker.tsx
  85. 3 29
      frontend/src/components/spool-form/types.ts
  86. 0 1
      frontend/src/components/spoolbuddy/AmsUnitCard.tsx
  87. 13 40
      frontend/src/components/spoolbuddy/AssignToAmsModal.tsx
  88. 3 17
      frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx
  89. 1 1
      frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx
  90. 5 17
      frontend/src/components/spoolbuddy/SpoolInfoCard.tsx
  91. 1 1
      frontend/src/components/spoolbuddy/TagDetectedModal.tsx
  92. 1 7
      frontend/src/hooks/useSpoolBuddyState.ts
  93. 7 61
      frontend/src/i18n/locales/de.ts
  94. 7 61
      frontend/src/i18n/locales/en.ts
  95. 6 60
      frontend/src/i18n/locales/fr.ts
  96. 7 61
      frontend/src/i18n/locales/it.ts
  97. 7 61
      frontend/src/i18n/locales/ja.ts
  98. 7 61
      frontend/src/i18n/locales/pt-BR.ts
  99. 7 61
      frontend/src/i18n/locales/zh-CN.ts
  100. 7 61
      frontend/src/i18n/locales/zh-TW.ts

File diff suppressed because it is too large
+ 1 - 1
CHANGELOG.md


+ 0 - 318
backend/app/api/routes/_spoolman_helpers.py

@@ -1,318 +0,0 @@
-"""Pure helper functions for Spoolman spool mapping.
-
-No heavy dependencies — importable in unit tests without the full backend stack.
-"""
-
-from __future__ import annotations
-
-import ipaddress
-import json
-import logging
-import math
-import re
-from typing import Any
-from urllib.parse import urlparse
-
-from typing_extensions import TypedDict
-
-logger = logging.getLogger(__name__)
-
-
-class MappedSpoolFields(TypedDict):
-    """Full shape of the dict returned by _map_spoolman_spool (InventorySpool-compatible)."""
-
-    id: int
-    material: str | None
-    subtype: str | None
-    brand: str | None
-    color_name: str | None
-    rgba: str | None
-    label_weight: int | None
-    core_weight: int | None
-    core_weight_catalog_id: None
-    weight_used: float | None
-    weight_locked: bool
-    last_scale_weight: None
-    last_weighed_at: None
-    slicer_filament: None
-    slicer_filament_name: str | None
-    nozzle_temp_min: int | None
-    nozzle_temp_max: None
-    note: str | None
-    added_full: None
-    last_used: str | None
-    encode_time: str | None
-    tag_uid: str | None
-    tray_uuid: str | None
-    data_origin: str | None
-    tag_type: str | None
-    archived_at: str | None
-    created_at: str | None  # None when Spoolman spool has no registered timestamp
-    updated_at: str | None
-    cost_per_kg: float | None
-    storage_location: str | None
-    k_profiles: list[Any]
-
-
-class NormalizedVendorRef(TypedDict):
-    """Vendor reference embedded in a NormalizedFilament."""
-
-    id: int
-    name: str
-
-
-class NormalizedFilament(TypedDict):
-    """Normalised Spoolman filament dict returned by the /filaments catalog endpoint."""
-
-    id: int
-    name: str
-    material: str | None
-    color_hex: str | None
-    color_name: str | None
-    weight: int | None
-    spool_weight: float | None
-    vendor: NormalizedVendorRef | None
-
-
-_CLOUD_METADATA_IPS = frozenset(
-    {
-        # AWS / GCP / Azure / Oracle / DigitalOcean IMDS
-        ipaddress.ip_address("169.254.169.254"),
-        # Alibaba Cloud metadata
-        ipaddress.ip_address("100.100.100.200"),
-        # AWS IMDS IPv6
-        ipaddress.ip_address("fd00:ec2::254"),
-    }
-)
-
-
-def assert_safe_spoolman_url(url: str) -> None:
-    """Raise ValueError if *url* should be blocked as an SSRF risk.
-
-    Bambuddy is typically deployed on a home LAN alongside Spoolman, so
-    loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
-    172.16-31.x) must be permitted — they are THE normal Spoolman topology.
-    This guard therefore targets the genuinely dangerous cases only.
-
-    Checks performed:
-    - Scheme must be http or https (no file://, gopher://, dict://, etc.).
-    - Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
-      (e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
-      ``ValueError`` for these forms so they would otherwise bypass the
-      explicit-IP block below, but libc (and browsers) resolve them as valid
-      IPv4 addresses.
-    - Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
-      fd00:ec2::254) are blocked — the classic SSRF credential-exfil target.
-    - Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
-      are blocked — pointless as a destination and suggests misuse.
-    - IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
-      bypass the checks above.
-
-    Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
-    are out of scope — DNS resolution is deliberately not performed here.
-    """
-    parsed = urlparse(url)
-    if parsed.scheme.lower() not in ("http", "https"):
-        raise ValueError("Spoolman URL must use http or https")
-
-    hostname = (parsed.hostname or "").lower()
-
-    # Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
-    # http://0x7f000001/). These slip past ipaddress.ip_address() but libc
-    # (and browsers) parse them as IPv4 — an obvious bypass if not caught.
-    if re.match(r"^(0x[0-9a-f]+|[0-9]+)$", hostname, re.I):
-        raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
-
-    try:
-        addr = ipaddress.ip_address(hostname)
-    except ValueError:
-        # Not a bare IP address — includes intentional cases such as "localhost" and
-        # RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
-        # a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
-        # Running Spoolman on the same host or home LAN is the standard Bambuddy
-        # topology, so loopback and private ranges are deliberately NOT blocked here.
-        return
-
-    # Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
-    # encode a blocked IPv4 into an IPv6 literal to bypass the check.
-    effective: ipaddress.IPv4Address | ipaddress.IPv6Address = addr
-    if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
-        effective = addr.ipv4_mapped
-
-    if effective in _CLOUD_METADATA_IPS:
-        raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
-
-    if effective.is_multicast or effective.is_unspecified:
-        raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
-
-
-_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
-_TAG_HEX_RE = re.compile(r"^[0-9A-F]+$")
-
-
-def _safe_int(value: object, fallback: int) -> int:
-    """Convert value to int, returning fallback for None/NaN/Inf/non-numeric."""
-    try:
-        f = float(value)  # type: ignore[arg-type]
-        if math.isfinite(f):
-            return int(f)
-    except (TypeError, ValueError):
-        pass
-    return fallback
-
-
-def _safe_float(value: object, fallback: float) -> float:
-    """Convert value to float, returning fallback for None/NaN/Inf/non-numeric."""
-    try:
-        f = float(value)  # type: ignore[arg-type]
-        if math.isfinite(f):
-            return f
-    except (TypeError, ValueError):
-        pass
-    return fallback
-
-
-def _safe_optional_float(value: object) -> float | None:
-    """Convert value to finite float, or None if missing/NaN/Infinite/non-numeric.
-
-    Used for optional monetary fields (price) to prevent Infinity/NaN from
-    reaching JSON serialisation, which raises ValueError with allow_nan=False.
-    """
-    if value is None:
-        return None
-    try:
-        f = float(value)  # type: ignore[arg-type]
-        if math.isfinite(f):
-            return f
-    except (TypeError, ValueError):
-        pass
-    return None
-
-
-def _extract_extra_str(extra: dict, key: str) -> str:
-    """Extract a JSON-encoded string from a Spoolman extra dict.
-
-    Spoolman stores extra values as JSON-stringified text — a stored string
-    "GFL05" appears as `'"GFL05"'` (six chars including the quotes). This
-    unwraps that, returning the bare string. Returns "" for missing keys,
-    non-strings, or invalid JSON.
-    """
-    raw = extra.get(key)
-    if not isinstance(raw, str):
-        return ""
-    try:
-        decoded = json.loads(raw)
-    except (json.JSONDecodeError, ValueError):
-        # Tolerate bare-string values written without JSON encoding.
-        return raw
-    return decoded if isinstance(decoded, str) else ""
-
-
-def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
-    """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
-
-    Fields not supported by Spoolman (k_profiles, slicer_filament, …) are
-    returned as None / empty so the frontend can still render them without
-    errors.  The ``data_origin`` field is set to ``"spoolman"`` so UI code can
-    distinguish these spools from local ones.
-    """
-    raw_id = spool.get("id")
-    if raw_id is None:
-        raise ValueError("Spoolman spool is missing required 'id' field")
-    try:
-        spool_id: int = int(raw_id)
-    except (TypeError, ValueError):
-        raise ValueError(f"Spoolman spool 'id' is not a valid integer: {raw_id!r}")
-    if spool_id <= 0:
-        raise ValueError(f"Spoolman spool 'id' must be a positive integer, got {spool_id}")
-
-    filament: dict = spool.get("filament") or {}
-    if not filament:
-        logger.warning(
-            "Spoolman spool %s has no filament data — all filament fields will use defaults",
-            spool_id,
-        )
-    vendor: dict = filament.get("vendor") or {}
-    extra: dict = spool.get("extra") or {}
-
-    # RFID tag stored as JSON-encoded string in Spoolman extra.tag.
-    # 32-char hex → Bambu Lab tray UUID; 8–30-char hex → NFC tag UID.
-    # Accepting the full realistic UID range (4-byte = 8 chars, 7-byte = 14 chars,
-    # 10-byte = 20 chars) avoids silently dropping valid SpoolBuddy-written tags.
-    raw_tag: str = (extra.get("tag") or "").strip('"').upper()
-    _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag))
-    tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None
-    tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None
-
-    # Subtype = filament name with material prefix stripped
-    material: str = (filament.get("material") or "").strip()
-    filament_name: str = (filament.get("name") or "").strip()
-    if material and filament_name.upper().startswith(material.upper()):
-        subtype: str | None = filament_name[len(material) :].strip() or None
-    else:
-        subtype = filament_name or None
-
-    # Colour: validate as 6-char hex; fall back to neutral grey for invalid values
-    raw_color = (filament.get("color_hex") or "").upper().removeprefix("#")
-    color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080"
-    rgba: str = color_hex + "FF"
-
-    label_weight: int = _safe_int(filament.get("weight"), 1000)
-    used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
-
-    # Archived state – Spoolman uses a boolean ``archived`` field
-    archived: bool = spool.get("archived", False)
-    archived_at: str | None = None
-    if archived:
-        archived_at = spool.get("last_used") or spool.get("registered") or None
-
-    created_at: str | None = spool.get("registered") or None
-
-    color_name: str | None = filament.get("color_name") or None
-
-    nozzle_temp_raw = filament.get("settings_extruder_temp")
-    nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None
-
-    return {
-        "id": spool_id,
-        "material": material,
-        "subtype": subtype,
-        "color_name": color_name,
-        "rgba": rgba,
-        "brand": vendor.get("name") or None,
-        "label_weight": label_weight,
-        "core_weight": _safe_int(
-            spool.get("spool_weight") if spool.get("spool_weight") is not None else filament.get("spool_weight"), 250
-        ),
-        "core_weight_catalog_id": None,
-        "weight_used": used_weight,
-        "weight_locked": False,
-        "last_scale_weight": None,
-        "last_weighed_at": None,
-        # BambuStudio slicer preset — Spoolman has no native field, so the
-        # update endpoint persists these under bambu_slicer_filament[_name]
-        # in the spool's extra dict. Values are JSON-encoded strings; an
-        # empty string ("") means cleared. Falls back to Spoolman's
-        # filament_name for slicer_filament_name when nothing is stored.
-        "slicer_filament": (_extract_extra_str(extra, "bambu_slicer_filament") or None),
-        "slicer_filament_name": (_extract_extra_str(extra, "bambu_slicer_filament_name") or (filament_name or None)),
-        "nozzle_temp_min": nozzle_temp_min,
-        "nozzle_temp_max": None,
-        "note": spool.get("comment") or None,
-        "added_full": None,
-        "last_used": spool.get("last_used"),
-        # encode_time semantics differ: local records NFC write time; Spoolman first_used
-        # records first print use — different events; using first_used as best available proxy.
-        "encode_time": spool.get("first_used"),
-        "tag_uid": tag_uid,
-        "tray_uuid": tray_uuid,
-        "data_origin": "spoolman",
-        "tag_type": "spoolman",
-        "archived_at": archived_at,
-        "created_at": created_at,
-        # Spoolman has no updated_at field; use registered timestamp as best available proxy
-        "updated_at": created_at,
-        "cost_per_kg": _safe_optional_float(spool.get("price")),
-        "storage_location": spool.get("location") or None,
-        "k_profiles": [],
-    }

+ 16 - 51
backend/app/api/routes/inventory.py

@@ -38,20 +38,28 @@ from backend.app.schemas.spool import (
     normalize_extra_colors,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
-from backend.app.utils.filament_ids import (
-    GENERIC_FILAMENT_IDS,
-    MATERIAL_TEMPS,
-    filament_id_to_setting_id,
-    normalize_slicer_filament,
-)
+from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
 logger = logging.getLogger(__name__)
 
-_GENERIC_ID_VALUES = set(GENERIC_FILAMENT_IDS.values())
-
 router = APIRouter(prefix="/inventory", tags=["inventory"])
 
+# Material temperature defaults (nozzle min/max)
+MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
+    "PLA": (190, 230),
+    "PETG": (220, 260),
+    "ABS": (240, 270),
+    "ASA": (240, 270),
+    "TPU": (200, 240),
+    "PA": (260, 290),
+    "PC": (250, 280),
+    "PVA": (190, 210),
+    "PLA-CF": (210, 240),
+    "PETG-CF": (240, 270),
+    "PA-CF": (270, 300),
+}
+
 # FilamentColors.xyz API
 FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
 
@@ -300,49 +308,6 @@ async def apply_spool_to_slot_via_mqtt(
             filament_id=cali_filament_id,
             nozzle_diameter=nozzle_diameter,
         )
-    else:
-        # No stored K-profile for this slot — preserve the slot's current live
-        # cali_idx if the printer has one. cali_idx is read from state.raw_data
-        # using the same idiom as the route's `current_tray_info_idx` lookup.
-        # Negative values (e.g. -1) mean "no calibration recorded" and must not
-        # be sent.
-        live_cali_idx: int | None = None
-        if state and getattr(state, "raw_data", None):
-            if ams_id == 255:
-                for vt in state.raw_data.get("vt_tray") or []:
-                    if isinstance(vt, dict) and int(vt.get("id", 254)) == (tray_id + 254):
-                        raw = vt.get("cali_idx")
-                        if isinstance(raw, int):
-                            live_cali_idx = raw
-                        break
-            else:
-                ams_section = state.raw_data.get("ams", {})
-                ams_list = (
-                    ams_section.get("ams", [])
-                    if isinstance(ams_section, dict)
-                    else ams_section
-                    if isinstance(ams_section, list)
-                    else []
-                )
-                tray_dict = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
-                if tray_dict:
-                    raw = tray_dict.get("cali_idx")
-                    if isinstance(raw, int):
-                        live_cali_idx = raw
-        if live_cali_idx is not None and live_cali_idx >= 0:
-            cali_filament_id = spool.slicer_filament or effective_tray_info_idx
-            client.extrusion_cali_sel(
-                ams_id=ams_id,
-                tray_id=tray_id,
-                cali_idx=live_cali_idx,
-                filament_id=cali_filament_id,
-                nozzle_diameter=nozzle_diameter,
-            )
-            logger.info(
-                "No stored K-profile for spool %d — preserved live cali_idx=%d",
-                spool.id,
-                live_cali_idx,
-            )
 
     # Persist slot preset mapping for UI display (preset_name on hover card).
     try:

+ 64 - 337
backend/app/api/routes/printers.py

@@ -300,7 +300,6 @@ async def delete_printer(
 
     from backend.app.models.archive import PrintArchive
     from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
-    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
@@ -318,9 +317,6 @@ async def delete_printer(
 
         await db.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
 
-    # Delete slot assignments for this printer (SQLite doesn't enforce FK cascades)
-    await db.execute(sql_delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
-
     # Delete maintenance history and items for this printer
     # (SQLite doesn't enforce FK cascades, so do it explicitly)
     maintenance_ids = (
@@ -1944,7 +1940,6 @@ async def configure_ams_slot(
     kprofile_filament_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     k_value: float = Query(0.0),
-    db: AsyncSession = Depends(get_db),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
@@ -2065,39 +2060,6 @@ async def configure_ams_slot(
     # Send filament setting + K-profile commands
     filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
 
-    # Realign the slot's filament context to the K-profile's calibration
-    # context. The printer's calibration table is keyed by (filament_id,
-    # cali_idx) — so for the cali_idx selected via extrusion_cali_sel to
-    # actually stick to the slot, ams_filament_setting must declare the
-    # slot under the SAME filament_id.
-    #
-    # Without this, configure_ams_slot would send:
-    #   ams_filament_setting → tray_info_idx=GFL99 (generic from material)
-    #   extrusion_cali_sel    → filament_id=P4d64437 (kp's preset)
-    # ...and the cali_idx would silently be dropped to default because the
-    # slot's filament context (GFL99) doesn't match the kp's (P4d64437).
-    #
-    # This realignment fires only when the kp is targeted at a different
-    # preset than the user's filament selection AND the kp's preset is a
-    # valid tray_info_idx (GF* official, P* local — not PFUS* cloud-user
-    # which the slicer rejects in tray_info_idx).
-    effective_setting_id = setting_id
-    if (
-        kprofile_filament_id
-        and kprofile_filament_id != effective_tray_info_idx
-        and not kprofile_filament_id.startswith("PFUS")
-    ):
-        logger.info(
-            "[configure_ams_slot] realigning slot filament context to kp: tray_info_idx %r → %r, setting_id %r → %r",
-            effective_tray_info_idx,
-            kprofile_filament_id,
-            setting_id,
-            kprofile_setting_id or setting_id,
-        )
-        effective_tray_info_idx = kprofile_filament_id
-        if kprofile_setting_id:
-            effective_setting_id = kprofile_setting_id
-
     # Always send ams_set_filament_setting — the user explicitly clicked
     # "Configure Slot", so honor that.  Previous versions skipped this for
     # RFID-tagged slots to preserve the slicer eye icon, but printers cache
@@ -2112,7 +2074,7 @@ async def configure_ams_slot(
         tray_color=tray_color,
         nozzle_temp_min=nozzle_temp_min,
         nozzle_temp_max=nozzle_temp_max,
-        setting_id=effective_setting_id,
+        setting_id=setting_id,
     )
 
     if not success:
@@ -2155,144 +2117,6 @@ async def configure_ams_slot(
             cali_idx=cali_idx,
         )
 
-    # Persist the user's K-profile choice so it survives RFID re-reads and
-    # session restarts. Pre-Phase-13 this was ephemeral — the MQTT command
-    # took effect on the printer but bambuddy never recorded it, so the next
-    # `_apply_pa_after_refresh` cycle had no stored profile to re-assert.
-    if cali_idx >= 0:
-        try:
-            from sqlalchemy.orm import selectinload
-
-            from backend.app.models.spool_assignment import SpoolAssignment
-            from backend.app.models.spool_k_profile import SpoolKProfile
-            from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-            from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-            # Resolve slot's extruder index for the K-profile match key. Same
-            # logic as _apply_pa_after_refresh: external slots invert tray→extruder,
-            # AMS slots come from ams_extruder_map. Falls back to 0 (single-nozzle).
-            slot_state = printer_manager.get_status(printer_id)
-            slot_extruder: int | None = None
-            if slot_state and slot_state.ams_extruder_map:
-                if ams_id == 255:
-                    slot_extruder = 1 - tray_id
-                else:
-                    slot_extruder = slot_state.ams_extruder_map.get(str(ams_id))
-            kp_extruder = slot_extruder if slot_extruder is not None else 0
-
-            # Spoolman SlotAssignment first — has UniqueConstraint, idempotent.
-            sm_result = await db.execute(
-                select(SpoolmanSlotAssignment).where(
-                    SpoolmanSlotAssignment.printer_id == printer_id,
-                    SpoolmanSlotAssignment.ams_id == ams_id,
-                    SpoolmanSlotAssignment.tray_id == tray_id,
-                )
-            )
-            sm_assignment = sm_result.scalar_one_or_none()
-            if sm_assignment:
-                existing = await db.execute(
-                    select(SpoolmanKProfile).where(
-                        SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
-                        SpoolmanKProfile.printer_id == printer_id,
-                        SpoolmanKProfile.extruder == kp_extruder,
-                        SpoolmanKProfile.nozzle_diameter == nozzle_diameter,
-                    )
-                )
-                kp = existing.scalar_one_or_none()
-                if kp:
-                    kp.cali_idx = cali_idx
-                    kp.k_value = k_value or 0.0
-                    kp.setting_id = kprofile_setting_id or None
-                    kp.name = tray_sub_brands or None
-                else:
-                    db.add(
-                        SpoolmanKProfile(
-                            spoolman_spool_id=sm_assignment.spoolman_spool_id,
-                            printer_id=printer_id,
-                            extruder=kp_extruder,
-                            nozzle_diameter=nozzle_diameter,
-                            k_value=k_value or 0.0,
-                            name=tray_sub_brands or None,
-                            cali_idx=cali_idx,
-                            setting_id=kprofile_setting_id or None,
-                        )
-                    )
-                await db.commit()
-                logger.info(
-                    "[configure_ams_slot] Persisted Spoolman K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
-                    sm_assignment.spoolman_spool_id,
-                    printer_id,
-                    ams_id,
-                    tray_id,
-                    cali_idx,
-                )
-            else:
-                # Local SpoolAssignment + SpoolKProfile (no UNIQUE — use .first())
-                local_result = await db.execute(
-                    select(SpoolAssignment)
-                    .options(selectinload(SpoolAssignment.spool))
-                    .where(
-                        SpoolAssignment.printer_id == printer_id,
-                        SpoolAssignment.ams_id == ams_id,
-                        SpoolAssignment.tray_id == tray_id,
-                    )
-                )
-                local_assignment = local_result.scalar_one_or_none()
-                if local_assignment and local_assignment.spool:
-                    existing = await db.execute(
-                        select(SpoolKProfile).where(
-                            SpoolKProfile.spool_id == local_assignment.spool.id,
-                            SpoolKProfile.printer_id == printer_id,
-                            SpoolKProfile.extruder == kp_extruder,
-                            SpoolKProfile.nozzle_diameter == nozzle_diameter,
-                        )
-                    )
-                    # SpoolKProfile has no unique constraint on this tuple, so
-                    # multiple rows could theoretically exist (shouldn't, but
-                    # don't crash if they do). Update the first match, leave
-                    # any duplicates alone.
-                    kp = existing.scalars().first()
-                    if kp:
-                        kp.cali_idx = cali_idx
-                        kp.k_value = k_value or 0.0
-                        kp.setting_id = kprofile_setting_id or None
-                        kp.name = tray_sub_brands or None
-                    else:
-                        db.add(
-                            SpoolKProfile(
-                                spool_id=local_assignment.spool.id,
-                                printer_id=printer_id,
-                                extruder=kp_extruder,
-                                nozzle_diameter=nozzle_diameter,
-                                k_value=k_value or 0.0,
-                                name=tray_sub_brands or None,
-                                cali_idx=cali_idx,
-                                setting_id=kprofile_setting_id or None,
-                            )
-                        )
-                    await db.commit()
-                    logger.info(
-                        "[configure_ams_slot] Persisted local K-profile spool=%d printer=%d ams=%d tray=%d cali_idx=%d",
-                        local_assignment.spool.id,
-                        printer_id,
-                        ams_id,
-                        tray_id,
-                        cali_idx,
-                    )
-        except Exception:
-            # MQTT command was already sent successfully — DB persist is best-effort.
-            logger.exception(
-                "[configure_ams_slot] Failed to persist K-profile (printer=%d ams=%d tray=%d cali_idx=%d)",
-                printer_id,
-                ams_id,
-                tray_id,
-                cali_idx,
-            )
-            try:
-                await db.rollback()
-            except Exception:
-                pass
-
     # Request fresh status push from printer so frontend gets updated data via WebSocket
     logger.info("[configure_ams_slot] Requesting status update from printer")
     update_result = client.request_status_update()
@@ -3021,17 +2845,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
         from backend.app.core.database import async_session
         from backend.app.models.spool import Spool
         from backend.app.models.spool_assignment import SpoolAssignment as SA
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-        from backend.app.services.spool_tag_matcher import (
-            ZERO_TAG_UID,
-            ZERO_TRAY_UUID,
-            is_bambu_tag,
-        )
-        from backend.app.utils.tag_normalization import (
-            normalize_tag_uid,
-            normalize_tray_uuid,
-        )
+        from backend.app.services.spool_tag_matcher import is_bambu_tag
 
         client = printer_manager.get_client(printer_id)
         if not client:
@@ -3057,171 +2871,84 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
         if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
             return
 
-        # Compute nozzle/extruder once — used by both local and Spoolman lookup.
-        nozzle_diameter = "0.4"
-        if state.nozzles:
-            nd = state.nozzles[0].nozzle_diameter
-            if nd:
-                nozzle_diameter = nd
-
-        slot_extruder = None
-        if state.ams_extruder_map:
-            if ams_id == 255:
-                # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                slot_extruder = 1 - slot_id
-            else:
-                slot_extruder = state.ams_extruder_map.get(str(ams_id))
-
-        # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile
-        # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path
-        # and exited silently if no SpoolKProfile match; Spoolman-assigned slots
-        # were ignored entirely and live cali_idx was never re-asserted.
-        matching_cali_idx: int | None = None
-        matching_filament_id: str = tray_info_idx
-
         async with async_session() as db:
-            from sqlalchemy import or_, select as sa_select
+            from sqlalchemy import select as sa_select
             from sqlalchemy.orm import selectinload
 
-            # Stage 1: local SpoolAssignment + SpoolKProfile match
             result = await db.execute(
                 sa_select(SA)
                 .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
                 .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
             )
             assignment = result.scalar_one_or_none()
-            spool: Spool | None = assignment.spool if assignment else None
-
-            # Stage 1b: tag-based fallback. The slot may have just been reset
-            # (SpoolAssignment row deleted) before the user triggered a re-read.
-            # The live tray already carries the spool's tray_uuid/tag_uid from
-            # the RFID re-read, but the SA row hasn't been re-created yet.
-            # Without this fallback we miss the stored SpoolKProfile and Stage 3
-            # ends up re-asserting whatever cali_idx the firmware reset to
-            # (typically the default profile).
-            if spool is None:
-                norm_uuid = normalize_tray_uuid(tray_uuid) if tray_uuid else ""
-                norm_tag = normalize_tag_uid(tag_uid) if tag_uid else ""
-                tag_filters = []
-                if norm_uuid and norm_uuid != ZERO_TRAY_UUID:
-                    tag_filters.append(Spool.tray_uuid == norm_uuid)
-                if norm_tag and norm_tag != ZERO_TAG_UID:
-                    tag_filters.append(Spool.tag_uid == norm_tag)
-                if tag_filters:
-                    tag_lookup = await db.execute(
-                        sa_select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
-                    )
-                    spool = tag_lookup.scalar_one_or_none()
-                    if spool is not None:
-                        logger.info(
-                            "PA re-apply AMS%d-T%d: matched spool %d via tag fallback "
-                            "(SpoolAssignment row missing, likely after slot reset)",
-                            ams_id,
-                            slot_id,
-                            spool.id,
-                        )
-
-            if spool is not None and spool.k_profiles:
-                # Prefer exact extruder match, fall back to extruder-agnostic kp
-                # for the same printer + nozzle. Hard-skipping on extruder
-                # mismatch made the cascade refuse perfectly valid stored
-                # profiles whenever the AMS-extruder mapping had shifted since
-                # calibration time, falling all the way through to Stage 3 and
-                # re-asserting the firmware default.
-                exact_kp = None
-                fallback_kp = None
-                for kp in spool.k_profiles:
-                    if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
+            if not assignment or not assignment.spool or not assignment.spool.k_profiles:
+                return
+
+            spool = assignment.spool
+            nozzle_diameter = "0.4"
+            if state.nozzles:
+                nd = state.nozzles[0].nozzle_diameter
+                if nd:
+                    nozzle_diameter = nd
+
+            # Determine slot's extruder from ams_extruder_map
+            slot_extruder = None
+            if state.ams_extruder_map:
+                if ams_id == 255:
+                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+                    slot_extruder = 1 - slot_id  # 0→1, 1→0
+                else:
+                    slot_extruder = state.ams_extruder_map.get(str(ams_id))
+
+            matching_kp = None
+            for kp in spool.k_profiles:
+                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
+                    if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
                         continue
-                    if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
-                        exact_kp = kp
-                        break
-                    if fallback_kp is None:
-                        fallback_kp = kp
-                chosen_kp = exact_kp or fallback_kp
-                if chosen_kp is not None:
-                    matching_cali_idx = chosen_kp.cali_idx
-                    # The filament_id in extrusion_cali_sel must match the preset
-                    # under which the K-profile was calibrated. Prefer the spool's
-                    # slicer_filament setting, falling back to the tray's RFID value.
-                    matching_filament_id = spool.slicer_filament or tray_info_idx
-
-            # Stage 2: Spoolman SpoolmanSlotAssignment + SpoolmanKProfile match
-            # (only when no local spool was matched — local takes priority,
-            # including the tag-based fallback above)
-            if matching_cali_idx is None and spool is None:
-                sm_result = await db.execute(
-                    sa_select(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == printer_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == slot_id,
-                    )
-                )
-                sm_assignment = sm_result.scalar_one_or_none()
-                if sm_assignment:
-                    kp_result = await db.execute(
-                        sa_select(SpoolmanKProfile).where(
-                            SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
-                            SpoolmanKProfile.printer_id == printer_id,
-                        )
-                    )
-                    for kp in kp_result.scalars().all():
-                        if kp.nozzle_diameter == nozzle_diameter:
-                            if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
-                                continue
-                            if kp.cali_idx is not None:
-                                matching_cali_idx = kp.cali_idx
-                                # Spoolman has no slicer_filament — use the tray's RFID value
-                                matching_filament_id = tray_info_idx
-                            break
-
-        # Stage 3: live tray.cali_idx fallback. Re-asserts the printer's current
-        # selection so the value sticks across the RFID re-read (otherwise some
-        # firmwares clear cali_idx back to -1 mid-cycle).
-        if matching_cali_idx is None:
-            live_cali_idx = tray.get("cali_idx")
-            if live_cali_idx is not None and live_cali_idx >= 0:
-                matching_cali_idx = live_cali_idx
-
-        if matching_cali_idx is None:
-            logger.debug(
-                "PA re-apply AMS%d-T%d: no stored or live cali_idx — skipping MQTT",
+                    matching_kp = kp
+                    break
+
+            if not matching_kp or matching_kp.cali_idx is None:
+                return
+
+            # The filament_id in extrusion_cali_sel must match the filament preset
+            # under which the K-profile was calibrated. Use spool.slicer_filament
+            # (the preset assigned in inventory), falling back to tray's RFID value.
+            kp_filament_id = spool.slicer_filament or tray_info_idx
+
+            logger.info(
+                "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
                 ams_id,
                 slot_id,
+                matching_kp.cali_idx,
+                kp_filament_id,
             )
-            return
-
-        logger.info(
-            "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
-            ams_id,
-            slot_id,
-            matching_cali_idx,
-            matching_filament_id,
-        )
 
-        # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
-        # "this is a manual config" which destroys the RFID-detected spool state
-        # (changes eye icon to pen icon in slicer).
-        client.extrusion_cali_sel(
-            ams_id=ams_id,
-            tray_id=slot_id,
-            cali_idx=matching_cali_idx,
-            filament_id=matching_filament_id,
-            nozzle_diameter=nozzle_diameter,
-        )
+            # 1. Select K-profile
+            # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
+            # "this is a manual config" which destroys the RFID-detected spool state
+            # (changes eye icon to pen icon in slicer).
+            client.extrusion_cali_sel(
+                ams_id=ams_id,
+                tray_id=slot_id,
+                cali_idx=matching_kp.cali_idx,
+                filament_id=kp_filament_id,
+                nozzle_diameter=nozzle_diameter,
+            )
 
-        # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
-        # selected the correct profile by cali_idx. Sending extrusion_cali_set with
-        # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
-        # nozzle_id, name), corrupting it.
+            # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
+            # selected the correct profile by cali_idx. Sending extrusion_cali_set with
+            # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
+            # nozzle_id, name), corrupting it.
 
-        logger.info(
-            "Applied PA profile cali_idx=%d to printer %d AMS%d-T%d",
-            matching_cali_idx,
-            printer_id,
-            ams_id,
-            slot_id,
-        )
+            logger.info(
+                "Applied PA profile cali_idx=%d k=%.3f to printer %d AMS%d-T%d",
+                matching_kp.cali_idx,
+                matching_kp.k_value or 0,
+                printer_id,
+                ams_id,
+                slot_id,
+            )
     except Exception as e:
         logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
 

+ 80 - 94
backend/app/api/routes/settings.py

@@ -10,7 +10,7 @@ from fastapi.responses import FileResponse, JSONResponse
 from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -22,17 +22,9 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/settings", tags=["settings"])
 
+# Default settings
 DEFAULT_SETTINGS = AppSettings()
 
-# Sensitive credential fields blanked for API-key callers
-_SENSITIVE_FIELDS_FOR_API_KEY = (
-    "mqtt_password",
-    "ha_token",
-    "prometheus_token",
-    "virtual_printer_access_code",
-    "ldap_bind_password",
-)
-
 
 async def get_setting(db: AsyncSession, key: str) -> str | None:
     """Get a single setting value by key."""
@@ -69,100 +61,94 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
     await upsert_setting(db, Settings, key, value)
 
 
-async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -> AppSettings:
-    """Build the full settings response, scrubbing secrets for API-key callers."""
+@router.get("", response_model=AppSettings)
+@router.get("/", response_model=AppSettings)
+async def get_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Get all application settings."""
     settings_dict = DEFAULT_SETTINGS.model_dump()
 
+    # Load saved settings from database
     result = await db.execute(select(Settings))
-    for setting in result.scalars().all():
-        if setting.key not in settings_dict:
-            continue
-        if setting.key in [
-            "auto_archive",
-            "save_thumbnails",
-            "capture_finish_photo",
-            "spoolman_enabled",
-            "spoolman_disable_weight_sync",
-            "spoolman_report_partial_usage",
-            "disable_filament_warnings",
-            "prefer_lowest_filament",
-            "check_updates",
-            "check_printer_firmware",
-            "include_beta_updates",
-            "virtual_printer_enabled",
-            "ftp_retry_enabled",
-            "mqtt_enabled",
-            "mqtt_use_tls",
-            "ha_enabled",
-            "per_printer_mapping_expanded",
-            "prometheus_enabled",
-            "user_notifications_enabled",
-            "queue_drying_enabled",
-            "queue_drying_block",
-            "ambient_drying_enabled",
-            "require_plate_clear",
-            "queue_shortest_first",
-            "default_bed_levelling",
-            "default_flow_cali",
-            "default_vibration_cali",
-            "default_layer_inspect",
-            "default_timelapse",
-            "ldap_enabled",
-            "ldap_auto_provision",
-        ]:
-            settings_dict[setting.key] = setting.value.lower() == "true"
-        elif setting.key in [
-            "default_filament_cost",
-            "energy_cost_per_kwh",
-            "ams_temp_good",
-            "ams_temp_fair",
-            "library_disk_warning_gb",
-            "low_stock_threshold",
-        ]:
-            settings_dict[setting.key] = float(setting.value)
-        elif setting.key in [
-            "ams_humidity_good",
-            "ams_humidity_fair",
-            "ams_history_retention_days",
-            "ftp_retry_count",
-            "ftp_retry_delay",
-            "ftp_timeout",
-            "mqtt_port",
-            "stagger_group_size",
-            "stagger_interval_minutes",
-            "forecast_global_lead_time_days",
-        ]:
-            settings_dict[setting.key] = int(setting.value)
-        elif setting.key == "default_printer_id":
-            settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
-        else:
-            settings_dict[setting.key] = setting.value
+    db_settings = result.scalars().all()
+
+    for setting in db_settings:
+        if setting.key in settings_dict:
+            # Parse the value based on the expected type
+            if setting.key in [
+                "auto_archive",
+                "save_thumbnails",
+                "capture_finish_photo",
+                "spoolman_enabled",
+                "spoolman_disable_weight_sync",
+                "spoolman_report_partial_usage",
+                "disable_filament_warnings",
+                "prefer_lowest_filament",
+                "check_updates",
+                "check_printer_firmware",
+                "include_beta_updates",
+                "virtual_printer_enabled",
+                "ftp_retry_enabled",
+                "mqtt_enabled",
+                "mqtt_use_tls",
+                "ha_enabled",
+                "per_printer_mapping_expanded",
+                "prometheus_enabled",
+                "user_notifications_enabled",
+                "queue_drying_enabled",
+                "queue_drying_block",
+                "ambient_drying_enabled",
+                "require_plate_clear",
+                "queue_shortest_first",
+                "default_bed_levelling",
+                "default_flow_cali",
+                "default_vibration_cali",
+                "default_layer_inspect",
+                "default_timelapse",
+                "ldap_enabled",
+                "ldap_auto_provision",
+            ]:
+                settings_dict[setting.key] = setting.value.lower() == "true"
+            elif setting.key in [
+                "default_filament_cost",
+                "energy_cost_per_kwh",
+                "ams_temp_good",
+                "ams_temp_fair",
+                "library_disk_warning_gb",
+                "low_stock_threshold",
+            ]:
+                settings_dict[setting.key] = float(setting.value)
+            elif setting.key in [
+                "ams_humidity_good",
+                "ams_humidity_fair",
+                "ams_history_retention_days",
+                "ftp_retry_count",
+                "ftp_retry_delay",
+                "ftp_timeout",
+                "mqtt_port",
+                "stagger_group_size",
+                "stagger_interval_minutes",
+                "forecast_global_lead_time_days",
+            ]:
+                settings_dict[setting.key] = int(setting.value)
+            elif setting.key == "default_printer_id":
+                # Handle nullable integer
+                settings_dict[setting.key] = int(setting.value) if setting.value and setting.value != "None" else None
+            else:
+                settings_dict[setting.key] = setting.value
 
+    # Get Home Assistant settings (with environment variable overrides)
     ha_settings = await get_homeassistant_settings(db)
     settings_dict.update(ha_settings)
 
-    # ldap_bind_password is never returned to any caller
+    # Never return LDAP bind password in API responses
     settings_dict["ldap_bind_password"] = ""
 
-    if is_api_key:
-        for field in _SENSITIVE_FIELDS_FOR_API_KEY:
-            if field in settings_dict:
-                settings_dict[field] = ""
-
     return AppSettings(**settings_dict)
 
 
-@router.get("", response_model=AppSettings)
-@router.get("/", response_model=AppSettings)
-async def get_settings(
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    _is_api_key: bool = Depends(caller_is_api_key),
-):
-    """Get all application settings."""
-    return await _build_settings_response(db, is_api_key=_is_api_key)
-
-
 @router.put("/", response_model=AppSettings)
 async def update_settings(
     settings_update: AppSettingsUpdate,
@@ -216,8 +202,8 @@ async def update_settings(
         except Exception:
             pass  # Don't fail the settings update if MQTT reconfiguration fails
 
-    # Return updated settings (never scrub secrets on PUT — caller has SETTINGS_UPDATE permission)
-    return await _build_settings_response(db, is_api_key=False)
+    # Return updated settings
+    return await get_settings(db)
 
 
 @router.patch("/", response_model=AppSettings)

+ 88 - 433
backend/app/api/routes/spoolbuddy.py

@@ -1,14 +1,12 @@
 """SpoolBuddy device management API routes."""
 
 import asyncio
-import contextlib
 import json
 import logging
 import time
 from datetime import datetime, timedelta, timezone
 from urllib.parse import urlparse
 
-import httpx
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -36,12 +34,10 @@ from backend.app.schemas.spoolbuddy import (
     TagRemovedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
-    UpdateStatusRequest,
     WriteTagRequest,
     WriteTagResultRequest,
 )
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
-from backend.app.services.spoolman import SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError
 
 logger = logging.getLogger(__name__)
 
@@ -49,75 +45,10 @@ router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
 
 OFFLINE_THRESHOLD_SECONDS = 30
 ONLINE_BROADCAST_INTERVAL_SECONDS = 10
-_SSRF_WARN_THROTTLE_SECONDS = 60
 _spoolbuddy_online_last_broadcast: dict[str, float] = {}
-_ssrf_warn_last_broadcast: dict[str, float] = {}
 _diagnostic_results: dict[tuple[str, str], dict] = {}
 
 
-@contextlib.asynccontextmanager
-async def _translate_spoolbuddy_errors():
-    """Translate Spoolman typed exceptions to HTTP for SpoolBuddy endpoints."""
-    try:
-        yield
-    except SpoolmanNotFoundError as exc:
-        raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
-    except SpoolmanClientError as exc:
-        raise HTTPException(status_code=502, detail="Spoolman rejected the request") from exc
-    except SpoolmanUnavailableError as exc:
-        raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
-
-
-async def _get_spoolman_client_or_none(db: AsyncSession):
-    """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
-    from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
-    from backend.app.models.settings import Settings
-    from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
-
-    settings_result = await db.execute(select(Settings))
-    settings_dict = {s.key: s.value for s in settings_result.scalars().all()}
-    spoolman_url = settings_dict.get("spoolman_url", "").strip()
-    spoolman_enabled = settings_dict.get("spoolman_enabled", "false").lower() == "true" and bool(spoolman_url)
-
-    if not spoolman_enabled:
-        return None
-
-    # SSRF guard: reject dangerous schemes, cloud-metadata IPs (169.254.169.254, 100.100.100.200,
-    # fd00:ec2::254), multicast and unspecified addresses — loopback and RFC-1918 ranges are
-    # intentionally permitted (Spoolman commonly runs on the same host or home LAN).
-    try:
-        assert_safe_spoolman_url(spoolman_url)
-    except ValueError as exc:
-        logger.warning(
-            "Spoolman integration disabled: URL %r rejected by SSRF guard: %s",
-            spoolman_url,
-            exc,
-        )
-        now = time.monotonic()
-        if now - _ssrf_warn_last_broadcast.get(spoolman_url, 0) > _SSRF_WARN_THROTTLE_SECONDS:
-            _ssrf_warn_last_broadcast[spoolman_url] = now
-            await ws_manager.broadcast(
-                {
-                    "type": "spoolman_ssrf_blocked",
-                    "detail": "Spoolman URL was rejected by the SSRF guard",
-                }
-            )
-        return None
-
-    client = await get_spoolman_client()
-    if not client or client.base_url != spoolman_url.rstrip("/"):
-        try:
-            client = await init_spoolman_client(spoolman_url)
-        except ValueError as exc:
-            logger.warning(
-                "Spoolman integration disabled: URL %r rejected on re-initialisation: %s",
-                spoolman_url,
-                exc,
-            )
-            return None
-    return client
-
-
 def _is_online(device: SpoolBuddyDevice) -> bool:
     if not device.last_seen:
         return False
@@ -240,8 +171,8 @@ async def register_device(
         from backend.app.services.spoolbuddy_ssh import get_public_key
 
         response.ssh_public_key = await get_public_key()
-    except Exception as exc:
-        logger.warning("Could not attach SSH public key to heartbeat response: %s", exc)
+    except Exception:
+        pass  # Key not generated yet — daemon can still work without it
 
     return response
 
@@ -389,7 +320,6 @@ async def nfc_tag_scanned(
                 "type": "spoolbuddy_tag_matched",
                 "device_id": req.device_id,
                 "tag_uid": req.tag_uid,
-                "tray_uuid": req.tray_uuid,
                 "spool": {
                     "id": spool.id,
                     "material": spool.material,
@@ -403,105 +333,28 @@ async def nfc_tag_scanned(
                 },
             }
         )
-        logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
-        return {"status": "ok", "matched": True, "spool_id": spool.id}
-
-    # Local DB miss — fall back to Spoolman when enabled
-    from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
-
-    client = await _get_spoolman_client_or_none(db)
-    if client is not None:
-        try:
-            cached_spools = await client.get_spools()
-            sm_spool: dict | None = None
-            if req.tray_uuid:
-                sm_spool = await client.find_spool_by_tag(req.tray_uuid, cached_spools=cached_spools)
-            if sm_spool is None and req.tag_uid:
-                sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
-
-            if sm_spool is not None:
-                mapped = _map_spoolman_spool(sm_spool)
-                await ws_manager.broadcast(
-                    {
-                        "type": "spoolbuddy_tag_matched",
-                        "device_id": req.device_id,
-                        "tag_uid": req.tag_uid,
-                        "tray_uuid": req.tray_uuid,
-                        "spool": {
-                            "id": mapped["id"],
-                            "material": mapped["material"],
-                            "subtype": mapped["subtype"],
-                            "color_name": mapped["color_name"],
-                            "rgba": mapped["rgba"],
-                            "brand": mapped["brand"],
-                            "label_weight": mapped["label_weight"],
-                            "core_weight": mapped["core_weight"],
-                            "weight_used": mapped["weight_used"],
-                        },
-                    }
-                )
-                logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
-                return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
-        except ValueError as exc:
-            logger.error(
-                "Spoolman returned malformed spool data during tag lookup for %s: %s",
-                req.tag_uid,
-                exc,
-            )
-            return {"status": "ok", "matched": False, "spool_id": None}
-        except (httpx.RequestError, httpx.HTTPStatusError, SpoolmanUnavailableError):
-            logger.warning(
-                "Spoolman unreachable during tag lookup for %s",
-                req.tag_uid,
-            )
-            # Broadcast a diagnostic event so the UI can surface "Spoolman down" to the user.
-            # Use a distinct type from spoolbuddy_unknown_tag — Spoolman outage != unregistered spool.
-            await ws_manager.broadcast(
-                {
-                    "type": "spoolman_unavailable",
-                    "device_id": req.device_id,
-                    "context": "nfc_tag_scanned",
-                }
-            )
-            return {"status": "ok", "matched": False, "spool_id": None}
-        except Exception as exc:
-            logger.error(
-                "Spoolman tag lookup failed unexpectedly for %s: %s",
-                req.tag_uid,
-                exc,
-            )
-            # Broadcast a distinct error event so operators can distinguish
-            # "unexpected backend error" from "unregistered tag".
-            await ws_manager.broadcast(
-                {
-                    "type": "spoolbuddy_lookup_error",
-                    "device_id": req.device_id,
-                }
-            )
-            # Same silent-return policy: an unexpected error must not break device operation
-            # or trigger spurious duplicate-registration flows in the UI.
-            return {"status": "ok", "matched": False, "spool_id": None}
+        logger.info("SpoolBuddy tag matched: %s -> spool %d", req.tag_uid, spool.id)
+    else:
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_unknown_tag",
+                "device_id": req.device_id,
+                "tag_uid": req.tag_uid,
+                "sak": req.sak,
+                "tag_type": req.tag_type,
+            }
+        )
+        logger.info(
+            "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
+            req.tag_uid,
+            len(req.tag_uid or ""),
+            req.tray_uuid,
+            len(req.tray_uuid or ""),
+            req.tag_type,
+            req.sak,
+        )
 
-    await ws_manager.broadcast(
-        {
-            "type": "spoolbuddy_unknown_tag",
-            "device_id": req.device_id,
-            "tag_uid": req.tag_uid,
-            "tray_uuid": req.tray_uuid,
-            "sak": req.sak,
-            "tag_type": req.tag_type,
-        }
-    )
-    logger.info(
-        "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
-        req.tag_uid,
-        len(req.tag_uid or ""),
-        req.tray_uuid,
-        len(req.tray_uuid or ""),
-        req.tag_type,
-        req.sak,
-    )
-    return {"status": "ok", "matched": False, "spool_id": None}
+    return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
 
 
 @router.post("/nfc/tag-removed")
@@ -527,91 +380,38 @@ async def nfc_write_tag(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Queue an NFC tag write command for a SpoolBuddy device."""
+    import json
+
     from backend.app.models.spool import Spool
-    from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
+    from backend.app.services.opentag3d import encode_opentag3d
+
+    # Find the spool
+    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(status_code=404, detail="Spool not found")
 
-    # Find the device first (required regardless of spool source)
+    # Find the device
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
     device = result.scalar_one_or_none()
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    # Try local DB first
-    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = result.scalar_one_or_none()
-
-    nfc_warnings: list[str] = []
-    if spool:
-        ndef_data = encode_opentag3d(spool)
-        data_origin = "local"
-    else:
-        # Local DB miss — fall back to Spoolman when enabled
-        from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
-
-        sm_client = await _get_spoolman_client_or_none(db)
-        if sm_client is None:
-            raise HTTPException(status_code=404, detail="Spool not found")
-
-        async with _translate_spoolbuddy_errors():
-            sm_spool = await sm_client.get_spool(req.spool_id)
-
-        try:
-            mapped = _map_spoolman_spool(sm_spool)
-        except ValueError as exc:
-            logger.warning("Spoolman returned invalid spool for write-tag: %s", exc)
-            raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data")
-
-        if not mapped.get("material"):
-            raise HTTPException(
-                status_code=400,
-                detail="Spoolman spool has no material set — cannot encode NFC tag",
-            )
-
-        ndef_data = encode_opentag3d_from_mapped(mapped)
-        data_origin = "spoolman"
-
-        # Warn when fields that drive NFC content are absent in Spoolman.
-        if not mapped.get("color_name"):
-            nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name")
-        if not mapped.get("nozzle_temp_min"):
-            nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
-        if not mapped.get("subtype"):
-            nfc_warnings.append("subtype not set in Spoolman — tag encodes empty subtype")
-        if not mapped.get("brand"):
-            nfc_warnings.append("brand/vendor not set in Spoolman — tag encodes empty brand")
-        if not mapped.get("rgba"):
-            nfc_warnings.append("rgba not set in Spoolman — tag encodes default colour")
-        if not mapped.get("label_weight"):
-            nfc_warnings.append("label_weight not set in Spoolman — tag encodes 0 g")
-        if nfc_warnings:
-            logger.warning(
-                "NFC encode for Spoolman spool %d has incomplete data: %s",
-                req.spool_id,
-                "; ".join(nfc_warnings),
-            )
+    # Encode OpenTag3D NDEF data
+    ndef_data = encode_opentag3d(spool)
 
     # Store write payload and set pending command
     device.pending_write_payload = json.dumps(
         {
-            "spool_id": req.spool_id,
+            "spool_id": spool.id,
             "ndef_data_hex": ndef_data.hex(),
-            "data_origin": data_origin,
         }
     )
     device.pending_command = "write_tag"
     await db.commit()
 
-    logger.info(
-        "Write tag queued for device %s, spool %d (%s, %d bytes)",
-        req.device_id,
-        req.spool_id,
-        data_origin,
-        len(ndef_data),
-    )
-    result: dict = {"status": "queued"}
-    if nfc_warnings:
-        result["warnings"] = nfc_warnings
-    return result
+    logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data))
+    return {"status": "queued"}
 
 
 @router.post("/nfc/write-result")
@@ -621,134 +421,37 @@ async def nfc_write_result(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Handle NFC tag write result from SpoolBuddy daemon."""
-    # Find the device
+    # Find the device and clear pending state
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
     device = result.scalar_one_or_none()
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    # Capture data_origin before clearing the payload
-    try:
-        payload_dict = json.loads(device.pending_write_payload or "{}")
-    except (json.JSONDecodeError, TypeError):
-        payload_dict = {}
-        logger.warning("Malformed pending_write_payload for device %s — treating as local", req.device_id)
-    data_origin = payload_dict.get("data_origin", "local")
-
     device.pending_command = None
     device.pending_write_payload = None
 
     if req.success:
-        if data_origin == "spoolman":
-            # Update Spoolman extra.tag with the written NFC UID using a safe merge
-            # (fetches current extra first to avoid overwriting other custom fields).
-            sm_client = await _get_spoolman_client_or_none(db)
-            if sm_client is None:
-                logger.warning("Spoolman not configured; cannot persist tag link for spool %d", req.spool_id)
-                await db.commit()
-                await ws_manager.broadcast(
-                    {
-                        "type": "spoolbuddy_tag_link_failed",
-                        "device_id": req.device_id,
-                        "spool_id": req.spool_id,
-                        "tag_uid": req.tag_uid,
-                        "message": "Spoolman not configured",
-                    }
-                )
-                raise HTTPException(
-                    status_code=502,
-                    detail="Tag written to NFC but Spoolman is not configured; link not persisted",
-                )
-
-            _tag_link_ok = False
-            try:
-                tag_value = json.dumps(req.tag_uid.upper())
-                await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
-                logger.info(
-                    "Spoolman tag written and linked: spool %d -> tag %s",
-                    req.spool_id,
-                    req.tag_uid,
-                )
-                _tag_link_ok = True
-            except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError) as exc:
-                logger.error(
-                    "Spoolman error during tag write-back for spool %d (type=%s, status=%s): %s",
-                    req.spool_id,
-                    type(exc).__name__,
-                    getattr(exc, "status_code", "N/A"),
-                    exc,
-                )
-                # fall through to broadcast + raise 502 below
-            except Exception:
-                logger.exception(
-                    "Unexpected error during Spoolman tag write-back for spool %d",
-                    req.spool_id,
-                )
-                # fall through to broadcast + raise 502 below
-
-            await db.commit()
-            if _tag_link_ok:
-                await ws_manager.broadcast(
-                    {
-                        "type": "spoolbuddy_tag_written",
-                        "device_id": req.device_id,
-                        "spool_id": req.spool_id,
-                        "tag_uid": req.tag_uid,
-                    }
-                )
-            else:
-                await ws_manager.broadcast(
-                    {
-                        "type": "spoolbuddy_tag_link_failed",
-                        "device_id": req.device_id,
-                        "spool_id": req.spool_id,
-                        "tag_uid": req.tag_uid,
-                        # Generic message — full exception (may contain internal URLs/hostnames)
-                        # is logged server-side only to prevent information leakage via WebSocket.
-                        "message": "Spoolman link failed",
-                    }
-                )
-                raise HTTPException(
-                    status_code=502,
-                    detail="Tag written to NFC but Spoolman link failed",
-                )
-        else:
-            # Link the tag to the local DB spool
-            from backend.app.models.spool import Spool
-
-            result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-            spool = result.scalar_one_or_none()
-            if spool is None:
-                logger.warning(
-                    "NFC tag written for spool %d but it no longer exists in local DB; tag is orphaned",
-                    req.spool_id,
-                )
-                await db.commit()
-                await ws_manager.broadcast(
-                    {
-                        "type": "spoolbuddy_tag_link_failed",
-                        "device_id": req.device_id,
-                        "spool_id": req.spool_id,
-                        "message": "Spool not found",
-                    }
-                )
-                return {"status": "ok", "linked": False, "message": "Spool not found"}
+        # Link the tag to the spool
+        from backend.app.models.spool import Spool
 
+        result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+        spool = result.scalar_one_or_none()
+        if spool:
             spool.tag_uid = req.tag_uid.upper()
             spool.tag_type = "ntag"
             spool.data_origin = "opentag3d"
             spool.encode_time = datetime.now(timezone.utc)
             logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
 
-            await db.commit()
-            await ws_manager.broadcast(
-                {
-                    "type": "spoolbuddy_tag_written",
-                    "device_id": req.device_id,
-                    "spool_id": req.spool_id,
-                    "tag_uid": req.tag_uid,
-                }
-            )
+        await db.commit()
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_written",
+                "device_id": req.device_id,
+                "spool_id": req.spool_id,
+                "tag_uid": req.tag_uid,
+            }
+        )
     else:
         await db.commit()
         await ws_manager.broadcast(
@@ -813,66 +516,27 @@ async def update_spool_weight(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Update spool's used weight from scale reading."""
-    from backend.app.api.routes._spoolman_helpers import _safe_float
     from backend.app.models.spool import Spool
 
-    # Try local DB first — local spool IDs must not be forwarded to Spoolman.
-    db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = db_result.scalar_one_or_none()
-
-    if spool:
-        net_filament = max(0, req.weight_grams - spool.core_weight)
-        spool.weight_used = max(0, spool.label_weight - net_filament)
-        spool.last_scale_weight = req.weight_grams
-        spool.last_weighed_at = datetime.now(timezone.utc)
-        await db.commit()
-        logger.info(
-            "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
-            spool.id,
-            req.weight_grams,
-            spool.weight_used,
-        )
-        return {"status": "ok", "weight_used": spool.weight_used}
-
-    # Local miss — fall back to Spoolman when enabled.
-    sm_client = await _get_spoolman_client_or_none(db)
-    if sm_client is None:
+    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
         raise HTTPException(status_code=404, detail="Spool not found")
 
-    async with _translate_spoolbuddy_errors():
-        sm_spool = await sm_client.get_spool(req.spool_id)
-
-    filament = sm_spool.get("filament") or {}
-    spool_tare = sm_spool.get("spool_weight")
-    raw_tare = spool_tare if spool_tare is not None else filament.get("spool_weight")
-    spool_weight_warning: str | None = None
-    if raw_tare is None:
-        logger.warning(
-            "Spoolman spool %d has no spool_weight set; using 250g fallback for tare",
-            req.spool_id,
-        )
-        spool_weight_warning = (
-            "spool_weight_not_set: Spoolman filament has no spool_weight configured; weight estimate uses 250g fallback"
-        )
-    core_weight = _safe_float(raw_tare, 250.0)
-    label_weight = _safe_float(filament.get("weight"), 1000.0)
-    remaining_weight = max(0.0, req.weight_grams - core_weight)
-
-    async with _translate_spoolbuddy_errors():
-        await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
+    # net weight = total on scale minus empty spool core
+    net_filament = max(0, req.weight_grams - spool.core_weight)
+    spool.weight_used = max(0, spool.label_weight - net_filament)
+    spool.last_scale_weight = req.weight_grams
+    spool.last_weighed_at = datetime.now(timezone.utc)
+    await db.commit()
 
-    weight_used = max(0.0, label_weight - remaining_weight)
     logger.info(
-        "SpoolBuddy updated Spoolman spool %d: %.1fg on scale, core=%.1fg → %.1fg remaining",
-        req.spool_id,
+        "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
+        spool.id,
         req.weight_grams,
-        core_weight,
-        remaining_weight,
+        spool.weight_used,
     )
-    result: dict = {"status": "ok", "weight_used": weight_used}
-    if spool_weight_warning:
-        result["warnings"] = [spool_weight_warning]
-    return result
+    return {"status": "ok", "weight_used": spool.weight_used}
 
 
 # --- Calibration endpoints ---
@@ -1290,17 +954,8 @@ async def trigger_daemon_update(
         }
     )
 
-    # Run the SSH update in the background — hold reference to prevent GC cancellation
-    _ssh_update_task = asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
-    _ssh_update_task.add_done_callback(
-        lambda t: logger.error(
-            "SSH update task for device %s ended unexpectedly (cancelled=%s)",
-            device_id,
-            t.cancelled(),
-        )
-        if (t.cancelled() or t.exception() is not None)
-        else None
-    )
+    # Run the SSH update in the background
+    asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
 
     return {"status": "ok", "message": "SSH update started"}
 
@@ -1316,14 +971,13 @@ async def get_ssh_public_key(
         key = await get_public_key()
         return {"public_key": key}
     except Exception as e:
-        logger.error("Failed to get SSH public key: %s", e)
-        raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
+        raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e
 
 
 @router.post("/devices/{device_id}/update-status")
 async def report_update_status(
     device_id: str,
-    req: UpdateStatusRequest,
+    req: dict,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
@@ -1333,24 +987,25 @@ async def report_update_status(
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    device.update_status = req.status
-    device.update_message = req.message
-    # Only "complete" clears pending_command here. "error" leaves it set so the user can retry
-    # via the UI. The SSH service's own _update_progress clears on both "complete" and "error"
-    # because it owns the full update lifecycle end-to-end.
-    if req.status == "complete":
-        device.pending_command = None
-    await db.commit()
+    status = req.get("status", "")
+    message = req.get("message", "")
 
-    logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message)
-    await ws_manager.broadcast(
-        {
-            "type": "spoolbuddy_update",
-            "device_id": device_id,
-            "update_status": req.status,
-            "update_message": req.message,
-        }
-    )
+    if status in ("updating", "complete", "error"):
+        device.update_status = status
+        device.update_message = message[:255] if message else None
+        if status == "complete":
+            device.pending_command = None
+        await db.commit()
+
+        logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, status, message)
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_update",
+                "device_id": device_id,
+                "update_status": status,
+                "update_message": message,
+            }
+        )
 
     return {"status": "ok"}
 

+ 109 - 413
backend/app/api/routes/spoolman.py

@@ -2,38 +2,26 @@
 
 import json
 import logging
-from typing import Literal
 
 from fastapi import APIRouter, Depends, HTTPException
 from pydantic import BaseModel
-from sqlalchemy import delete, select, text
+from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.spool_assignment import SpoolAssignment
-from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import (
-    SpoolmanClientError,
-    SpoolmanNotFoundError,
-    SpoolmanUnavailableError,
     close_spoolman_client,
     get_spoolman_client,
     init_spoolman_client,
 )
-from backend.app.utils.filament_ids import (
-    GENERIC_FILAMENT_IDS,
-    MATERIAL_TEMPS,
-    normalize_slicer_filament,
-)
 
 logger = logging.getLogger(__name__)
 
@@ -51,10 +39,10 @@ class SpoolmanStatus(BaseModel):
 class SkippedSpool(BaseModel):
     """Information about a skipped spool during sync."""
 
-    location: str
-    reason: Literal["No RFID tag and no slot assignment"]
-    filament_type: str | None = None
-    color: str | None = None
+    location: str  # e.g., "AMS A1" or "External Spool"
+    reason: str  # e.g., "Not a Bambu Lab spool", "Empty tray"
+    filament_type: str | None = None  # e.g., "PLA", "PETG"
+    color: str | None = None  # Hex color
 
 
 class SyncResult(BaseModel):
@@ -141,21 +129,9 @@ async def connect_spoolman(
             )
 
         # Ensure the 'tag' extra field exists for RFID/UUID storage
-        field_ok = await client.ensure_tag_extra_field()
-        if not field_ok:
-            logger.error("Spoolman tag extra field registration failed — NFC tag links may not persist")
-        # Register slicer-preset extra fields (Spoolman rejects unknown extra keys).
-        for field_name in ("bambu_slicer_filament", "bambu_slicer_filament_name"):
-            if not await client.ensure_extra_field(field_name):
-                logger.warning(
-                    "Spoolman extra field %r registration failed — spool slicer-preset edits will return 502",
-                    field_name,
-                )
+        await client.ensure_tag_extra_field()
 
         return {"success": True, "message": f"Connected to Spoolman at {url}"}
-    except ValueError as exc:
-        logger.warning("Spoolman URL rejected: %s", exc)
-        raise HTTPException(status_code=400, detail=str(exc)) from exc
     except Exception as e:
         logger.error("Failed to connect to Spoolman: %s", e)
         raise HTTPException(status_code=503, detail=str(e))
@@ -219,6 +195,9 @@ async def sync_printer_ams(
     synced = 0
     skipped: list[SkippedSpool] = []
     errors = []
+    # Track tray UUIDs currently in the AMS (for clearing removed spools)
+    current_tray_uuids: set[str] = set()
+    synced_spool_ids: set[int] = set()
 
     # Handle different AMS data structures
     # Traditional AMS: list of {"id": N, "tray": [...]} dicts
@@ -239,10 +218,7 @@ async def sync_printer_ams(
     if not ams_units:
         raise HTTPException(
             status_code=400,
-            detail=(
-                "AMS data format not supported. Keys: "
-                f"{list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}"
-            ),
+            detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
         )
 
     # OPTIMIZATION: Fetch all spools once before processing trays
@@ -274,20 +250,6 @@ async def sync_printer_ams(
     except Exception as e:
         logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
 
-    # Load existing Spoolman slot assignments for the no-RFID fallback path
-    spoolman_slot_map: dict[tuple[int, int], int] = {}
-    try:
-        slot_result = await db.execute(
-            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
-        )
-        for slot in slot_result.scalars().all():
-            spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
-    except Exception as e:
-        logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
-
-    slot_changes: list[tuple[int, int, int]] = []  # (ams_id, tray_id, spoolman_spool_id)
-    empty_slots: list[tuple[int, int]] = []  # (ams_id, tray_id) now empty
-
     for ams_unit in ams_units:
         if not isinstance(ams_unit, dict):
             continue
@@ -299,19 +261,33 @@ async def sync_printer_ams(
             if not isinstance(tray_data, dict):
                 continue
 
-            tray_id_raw = int(tray_data.get("id", 0))
             tray = client.parse_ams_tray(ams_id, tray_data)
             if not tray:
-                empty_slots.append((ams_id, tray_id_raw))
+                continue  # Empty tray - nothing to sync
+
+            # Build location string for reporting
+            location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)
+
+            # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
+            if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
+                skipped.append(
+                    SkippedSpool(
+                        location=location,
+                        reason="Non-Bambu Lab spool (no RFID tag)",
+                        filament_type=tray.tray_type if tray.tray_type else None,
+                        color=tray.tray_color[:6] if tray.tray_color else None,
+                    )
+                )
                 continue
 
+            # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
             spool_tag = (
                 tray.tray_uuid
                 if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
                 else tray.tag_uid
             )
-
-            hint = spoolman_slot_map.get((ams_id, tray.tray_id)) if not spool_tag else None
+            if spool_tag:
+                current_tray_uuids.add(spool_tag.upper())
 
             try:
                 inv_remaining = inv_weights.get((ams_id, tray.tray_id))
@@ -321,12 +297,12 @@ async def sync_printer_ams(
                     disable_weight_sync=disable_weight_sync,
                     cached_spools=cached_spools,
                     inventory_remaining=inv_remaining,
-                    spoolman_spool_id_hint=hint,
                 )
                 if sync_result:
                     synced += 1
+                    # Add newly created spool to cache and track synced ID
                     if sync_result.get("id"):
-                        slot_changes.append((ams_id, tray.tray_id, sync_result["id"]))
+                        synced_spool_ids.add(sync_result["id"])
                         spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
                         if not spool_exists:
                             cached_spools.append(sync_result)
@@ -334,49 +310,23 @@ async def sync_printer_ams(
                     logger.info(
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                     )
-                elif spool_tag:
+                else:
+                    # Bambu Lab spool that wasn't synced (not found in Spoolman)
                     errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
-                elif not hint:
-                    skipped.append(
-                        SkippedSpool(
-                            location=f"AMS {ams_id} T{tray.tray_id}",
-                            reason="No RFID tag and no slot assignment",
-                            filament_type=tray.tray_type or None,
-                            color=tray.tray_color[:6] if tray.tray_color else None,
-                        )
-                    )
             except Exception as e:
                 error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
                 logger.error(error_msg)
                 errors.append(error_msg)
 
-    # Persist slot assignment changes to the local table
-    if slot_changes or empty_slots:
-        try:
-            for ams_id, tray_id, spool_id in slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for ams_id, tray_id in empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == printer_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
-        except Exception as e:
-            await db.rollback()
-            logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
-            errors.append(f"Failed to persist slot assignments: {type(e).__name__}")
+    # Clear location for spools that were removed from this printer's AMS
+    try:
+        cleared = await client.clear_location_for_removed_spools(
+            printer.name, current_tray_uuids, cached_spools=cached_spools, synced_spool_ids=synced_spool_ids
+        )
+        if cleared > 0:
+            logger.info("Cleared location for %s spools removed from %s", cleared, printer.name)
+    except Exception as e:
+        logger.error("Error clearing locations for removed spools: %s", e)
 
     return SyncResult(
         success=len(errors) == 0,
@@ -416,6 +366,10 @@ async def sync_all_printers(
     total_synced = 0
     all_skipped: list[SkippedSpool] = []
     all_errors = []
+    # Track tray UUIDs per printer (for clearing removed spools)
+    printer_tray_uuids: dict[str, set[str]] = {}
+    # Track synced spool IDs per printer (for location-based cleanup when no UUIDs available)
+    printer_synced_ids: dict[str, set[int]] = {}
 
     # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
     # This eliminates redundant API calls across all printers
@@ -443,20 +397,6 @@ async def sync_all_printers(
     except Exception as e:
         logger.debug("Could not load inventory assignments for weight fallback: %s", e)
 
-    # Load all Spoolman slot assignments for the no-RFID fallback
-    # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
-    all_slot_map: dict[tuple[int, int, int], int] = {}
-    try:
-        slot_result = await db.execute(select(SpoolmanSlotAssignment))
-        for slot in slot_result.scalars().all():
-            all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
-    except Exception as e:
-        logger.warning("Could not load Spoolman slot assignments: %s", e)
-
-    # Collect slot changes across all printers for a single DB write at the end
-    all_slot_changes: list[tuple[int, int, int, int]] = []  # (printer_id, ams_id, tray_id, spool_id)
-    all_empty_slots: list[tuple[int, int, int]] = []  # (printer_id, ams_id, tray_id)
-
     for printer in printers:
         state = printer_manager.get_status(printer.id)
         if not state or not state.raw_data:
@@ -466,6 +406,10 @@ async def sync_all_printers(
         if not ams_data:
             continue
 
+        # Initialize tracking sets for this printer
+        printer_tray_uuids[printer.name] = set()
+        printer_synced_ids[printer.name] = set()
+
         # Handle different AMS data structures
         # Traditional AMS: list of {"id": N, "tray": [...]} dicts
         # H2D/newer printers: dict with different structure
@@ -498,21 +442,36 @@ async def sync_all_printers(
                 if not isinstance(tray_data, dict):
                     continue
 
-                tray_id_raw = int(tray_data.get("id", 0))
                 tray = client.parse_ams_tray(ams_id, tray_data)
                 if not tray:
-                    all_empty_slots.append((printer.id, ams_id, tray_id_raw))
                     continue
 
+                # Build location string for reporting
+                location = f"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}"
+
+                # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
+                if not client.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
+                    all_skipped.append(
+                        SkippedSpool(
+                            location=location,
+                            reason="Non-Bambu Lab spool (no RFID tag)",
+                            filament_type=tray.tray_type if tray.tray_type else None,
+                            color=tray.tray_color[:6] if tray.tray_color else None,
+                        )
+                    )
+                    continue
+
+                # Track this spool tag as currently present in the AMS (prefer tray_uuid, fallback to tag_uid)
                 spool_tag = (
                     tray.tray_uuid
                     if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
                     else tray.tag_uid
                 )
-
-                hint = all_slot_map.get((printer.id, ams_id, tray.tray_id)) if not spool_tag else None
+                if spool_tag:
+                    printer_tray_uuids[printer.name].add(spool_tag.upper())
 
                 try:
+                    # Look up inventory weight as fallback when AMS data is invalid
                     inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id))
                     sync_result = await client.sync_ams_tray(
                         tray,
@@ -520,57 +479,33 @@ async def sync_all_printers(
                         disable_weight_sync=disable_weight_sync,
                         cached_spools=cached_spools,
                         inventory_remaining=inv_remaining,
-                        spoolman_spool_id_hint=hint,
                     )
                     if sync_result:
                         total_synced += 1
+                        # Track synced spool ID for cleanup
                         if sync_result.get("id"):
-                            all_slot_changes.append((printer.id, ams_id, tray.tray_id, sync_result["id"]))
+                            printer_synced_ids[printer.name].add(sync_result["id"])
+                            # Add newly created spool to cache
                             spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
                             if not spool_exists:
                                 cached_spools.append(sync_result)
                                 logger.debug("Added newly created spool %s to cache", sync_result["id"])
-                    elif spool_tag:
-                        all_errors.append(f"Spool not found in Spoolman: {printer.name} AMS {ams_id}:{tray.tray_id}")
-                    elif not hint:
-                        all_skipped.append(
-                            SkippedSpool(
-                                location=f"{printer.name} AMS {ams_id} T{tray.tray_id}",
-                                reason="No RFID tag and no slot assignment",
-                                filament_type=tray.tray_type or None,
-                                color=tray.tray_color[:6] if tray.tray_color else None,
-                            )
-                        )
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
-    # Persist slot assignment changes across all printers
-    if all_slot_changes or all_empty_slots:
+    # Clear location for spools that were removed from each printer's AMS
+    for printer_name, current_tray_uuids in printer_tray_uuids.items():
         try:
-            for p_id, ams_id, tray_id, spool_id in all_slot_changes:
-                await db.execute(
-                    text(
-                        "INSERT INTO spoolman_slot_assignments"
-                        " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                        " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                        " ON CONFLICT(printer_id, ams_id, tray_id)"
-                        " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                    ),
-                    {"printer_id": p_id, "ams_id": ams_id, "tray_id": tray_id, "spool_id": spool_id},
-                )
-            for p_id, ams_id, tray_id in all_empty_slots:
-                await db.execute(
-                    delete(SpoolmanSlotAssignment).where(
-                        SpoolmanSlotAssignment.printer_id == p_id,
-                        SpoolmanSlotAssignment.ams_id == ams_id,
-                        SpoolmanSlotAssignment.tray_id == tray_id,
-                    )
-                )
-            await db.commit()
+            cleared = await client.clear_location_for_removed_spools(
+                printer_name,
+                current_tray_uuids,
+                cached_spools=cached_spools,
+                synced_spool_ids=printer_synced_ids.get(printer_name, set()),
+            )
+            if cleared > 0:
+                logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
         except Exception as e:
-            await db.rollback()
-            logger.error("Error persisting Spoolman slot assignments: %s", e)
-            all_errors.append(f"Failed to persist slot assignments: {type(e).__name__}")
+            logger.error("Error clearing locations for %s: %s", printer_name, e)
 
     return SyncResult(
         success=len(all_errors) == 0,
@@ -782,249 +717,29 @@ async def link_spool(
 
     spool_tag = spool_tag.upper()
 
-    # Validate printer context when provided, but do NOT write spool.location —
-    # that field is user-managed in Spoolman. Slot assignment is stored locally.
-    printer_context: tuple[int, int, int] | None = None
+    # Build location like: "{Printer Name} - {AMS Name} {Slot Number}"
+    location: str | None = None
     if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
         printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
-        if not printer_result.scalar_one_or_none():
+        printer = printer_result.scalar_one_or_none()
+        if not printer:
             raise HTTPException(status_code=404, detail="Printer not found")
-        printer_context = (request.printer_id, request.ams_id, request.tray_id)
 
-    try:
-        await client.merge_spool_extra(spool_id, {"tag": json.dumps(spool_tag)})
-    except SpoolmanNotFoundError:
-        raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
-    except SpoolmanClientError:
-        raise HTTPException(status_code=502, detail="Spoolman rejected the request")
-    except SpoolmanUnavailableError:
-        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+        location = f"{printer.name} - {client.convert_ams_slot_to_location(request.ams_id, request.tray_id)}"
 
-    # Upsert slot assignment locally when printer context was supplied
-    if printer_context:
-        p_id, a_id, t_id = printer_context
-        try:
-            await db.execute(
-                text(
-                    "INSERT INTO spoolman_slot_assignments"
-                    " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                    " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                    " ON CONFLICT(printer_id, ams_id, tray_id)"
-                    " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                ),
-                {"printer_id": p_id, "ams_id": a_id, "tray_id": t_id, "spool_id": spool_id},
-            )
-            await db.commit()
-        except Exception as e:
-            await db.rollback()
-            logger.error(
-                "Linked spool %s in Spoolman but failed to persist local slot assignment "
-                "(printer=%s ams=%s tray=%s): %s",
-                spool_id,
-                p_id,
-                a_id,
-                t_id,
-                e,
-            )
-            raise HTTPException(
-                status_code=500,
-                detail=(
-                    "Spool linked in Spoolman but the local slot assignment could not be saved. "
-                    "Please re-open the link dialog to retry."
-                ),
-            ) from e
-
-    logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
-
-    # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
-    if printer_context:
-        p_id, a_id, t_id = printer_context
-        try:
-            spool_data = await client.get_spool(spool_id)
-            mapped = _map_spoolman_spool(spool_data)
-
-            mqtt_client = printer_manager.get_client(p_id)
-            if mqtt_client:
-                tray_type = mapped.get("material") or ""
-                brand = mapped.get("brand") or ""
-                subtype = mapped.get("subtype") or ""
-                if brand:
-                    tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
-                elif subtype:
-                    tray_sub_brands = f"{tray_type} {subtype}".strip()
-                else:
-                    tray_sub_brands = tray_type
-
-                tray_color = (mapped.get("rgba") or "808080FF").upper()
-                if len(tray_color) == 6:
-                    tray_color = tray_color + "FF"
-
-                material_upper = tray_type.upper().strip()
-                tray_info_idx = (
-                    GENERIC_FILAMENT_IDS.get(material_upper)
-                    or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
-                    or ""
-                )
-                setting_id = ""
-                temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
-                temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
-                temp_max = temp_defaults[1]
-
-                # Pull printer state via printer_manager (mqtt_client.printer_state
-                # was a non-existent attribute — the hasattr check silently
-                # returned None, defeating every state-based lookup below).
-                state = printer_manager.get_status(p_id)
-                nozzle_diameter = "0.4"
-                if state and state.nozzles:
-                    nd = state.nozzles[0].nozzle_diameter
-                    if nd:
-                        nozzle_diameter = nd
-
-                kp_result = await db.execute(
-                    select(SpoolmanKProfile).where(
-                        SpoolmanKProfile.spoolman_spool_id == spool_id,
-                        SpoolmanKProfile.printer_id == p_id,
-                    )
-                )
-                kp_rows = kp_result.scalars().all()
-                slot_extruder = None
-                if state and state.ams_extruder_map:
-                    if a_id == 255:
-                        slot_extruder = 1 - t_id
-                    else:
-                        slot_extruder = state.ams_extruder_map.get(str(a_id))
-
-                # Prefer exact extruder match, fall back to extruder-agnostic kp
-                # for the same nozzle. Hard-skip on extruder mismatch silently
-                # dropped valid stored profiles when the AMS-extruder map
-                # shifted since calibration.
-                exact_kp = None
-                fallback_kp = None
-                for kp in kp_rows:
-                    if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
-                        continue
-                    if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
-                        exact_kp = kp
-                        break
-                    if fallback_kp is None:
-                        fallback_kp = kp
-                matching_kp = exact_kp or fallback_kp
-
-                # Resolve printer-side calibration entry by cali_idx — the
-                # printer keys its calibration table by filament_id, not by
-                # setting_id. Stored kp.setting_id alone isn't enough.
-                printer_kp = None
-                if matching_kp and state and state.kprofiles:
-                    for pkp in state.kprofiles:
-                        if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
-                            printer_kp = pkp
-                            break
-
-                # Realign slot's filament context to the kp's calibration
-                # context so ams_filament_setting and extrusion_cali_sel
-                # reference the same preset; otherwise the printer drops the
-                # cali_idx to default. PFUS-prefix cloud-user presets are
-                # rejected by the slicer in tray_info_idx — skip realignment
-                # in that case.
-                effective_tray_info_idx = tray_info_idx
-                effective_setting_id = setting_id
-                if printer_kp and printer_kp.filament_id:
-                    if not printer_kp.filament_id.startswith("PFUS"):
-                        effective_tray_info_idx = printer_kp.filament_id
-                    if printer_kp.setting_id:
-                        effective_setting_id = printer_kp.setting_id
-                elif matching_kp and matching_kp.setting_id:
-                    derived = normalize_slicer_filament(matching_kp.setting_id)[0]
-                    if derived and not derived.startswith("PFUS"):
-                        effective_tray_info_idx = derived
-                    effective_setting_id = matching_kp.setting_id
-                if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
-                    logger.info(
-                        "Spoolman link: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
-                        tray_info_idx,
-                        effective_tray_info_idx,
-                        setting_id,
-                        effective_setting_id,
-                        matching_kp.id if matching_kp else None,
-                        "printer" if printer_kp else "stored",
-                    )
-
-                mqtt_client.ams_set_filament_setting(
-                    ams_id=a_id,
-                    tray_id=t_id,
-                    tray_info_idx=effective_tray_info_idx,
-                    tray_type=tray_type,
-                    tray_sub_brands=tray_sub_brands,
-                    tray_color=tray_color,
-                    nozzle_temp_min=temp_min,
-                    nozzle_temp_max=temp_max,
-                    setting_id=effective_setting_id,
-                )
-
-                if matching_kp and matching_kp.cali_idx is not None:
-                    cali_filament_id = (
-                        printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
-                    ) or effective_tray_info_idx
-                    mqtt_client.extrusion_cali_sel(
-                        ams_id=a_id,
-                        tray_id=t_id,
-                        cali_idx=matching_kp.cali_idx,
-                        filament_id=cali_filament_id,
-                        nozzle_diameter=nozzle_diameter,
-                    )
-                    logger.info(
-                        "Spoolman link: applied K-profile cali_idx=%d "
-                        "(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
-                        matching_kp.cali_idx,
-                        matching_kp.id,
-                        cali_filament_id,
-                        spool_id,
-                        p_id,
-                        a_id,
-                        t_id,
-                    )
-                else:
-                    from backend.app.api.routes.inventory import _find_tray_in_ams_data  # noqa: PLC0415
-
-                    live_tray = None
-                    if state and state.raw_data:
-                        ams_raw = state.raw_data.get("ams", [])
-                        if isinstance(ams_raw, dict):
-                            ams_raw = ams_raw.get("ams", [])
-                        live_tray = _find_tray_in_ams_data(ams_raw, a_id, t_id)
-                    live_cali_idx = (live_tray or {}).get("cali_idx")
-                    if live_cali_idx is not None and live_cali_idx >= 0:
-                        mqtt_client.extrusion_cali_sel(
-                            ams_id=a_id,
-                            tray_id=t_id,
-                            cali_idx=live_cali_idx,
-                            filament_id=effective_tray_info_idx,
-                            nozzle_diameter=nozzle_diameter,
-                        )
-
-                logger.info(
-                    "Auto-configured AMS slot ams=%d tray=%d after linking Spoolman spool %d on printer %d",
-                    a_id,
-                    t_id,
-                    spool_id,
-                    p_id,
-                )
-        except (SpoolmanNotFoundError, SpoolmanUnavailableError) as e:
-            logger.warning(
-                "Could not fetch Spoolman spool %d for MQTT configure after tag link: %s",
-                spool_id,
-                e,
-            )
-        except Exception:
-            logger.exception(
-                "Failed to auto-configure AMS slot after linking Spoolman spool %d (printer=%d ams=%d tray=%d)",
-                spool_id,
-                p_id,
-                a_id,
-                t_id,
-            )
+    # Update spool with tag
+    # Note: Spoolman extra field values must be valid JSON, so we encode the string
+    result = await client.update_spool(
+        spool_id=spool_id,
+        location=location,
+        extra={"tag": json.dumps(spool_tag)},
+    )
 
-    return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
+    if result:
+        logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
+        return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
+    else:
+        raise HTTPException(status_code=500, detail="Failed to update spool")
 
 
 @router.post("/spools/{spool_id}/unlink")
@@ -1049,33 +764,14 @@ async def unlink_spool(
     if not await client.health_check():
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
-    # Spoolman PATCHes the extra dict by MERGING with the existing keys —
-    # popping "tag" from a copy of the dict and sending the rest doesn't
-    # clear it; Spoolman keeps the old value because the key wasn't in the
-    # payload. To actually clear a key we must explicitly send it as the
-    # JSON-encoded empty string ('""'), which the read-side filters in
-    # _map_spoolman_spool and get_linked_spools strip via .strip('"').
-    #
-    # merge_spool_extra acquires extra_lock(spool_id) internally — wrapping
-    # this call in another `async with client.extra_lock(spool_id)` would
-    # deadlock (asyncio.Lock is not reentrant).
-    try:
-        await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
-    except SpoolmanNotFoundError:
-        raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
-    except SpoolmanClientError:
-        raise HTTPException(status_code=502, detail="Spoolman rejected the request")
-    except SpoolmanUnavailableError:
-        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+    result = await client.update_spool(
+        spool_id=spool_id,
+        clear_location=True,
+        extra={"tag": json.dumps("")},
+    )
 
-    # Remove local slot assignment for this spool (all slots — a spool can only be in one at a time)
-    try:
-        await db.execute(delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id))
-        await db.commit()
-    except Exception:
-        await db.rollback()
-        logger.exception("DB error removing slot assignment for spool %s", spool_id)
-        raise HTTPException(status_code=500, detail="Failed to remove local slot assignment")
-
-    logger.info("Unlinked Spoolman spool %s", spool_id)
-    return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
+    if result:
+        logger.info("Unlinked Spoolman spool %s", spool_id)
+        return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
+    else:
+        raise HTTPException(status_code=500, detail="Failed to update spool")

+ 0 - 1541
backend/app/api/routes/spoolman_inventory.py

@@ -1,1541 +0,0 @@
-"""Spoolman inventory proxy endpoints.
-
-Translates between Spoolman's data model and Bambuddy's internal
-InventorySpool format so the frontend can use a single unified inventory UI
-regardless of whether data comes from the local database or Spoolman.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import json
-import logging
-import re
-import time
-from contextlib import asynccontextmanager
-
-from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
-from fastapi.responses import JSONResponse
-from pydantic import BaseModel, Field, field_validator, model_validator
-from sqlalchemy import delete, select, text
-from sqlalchemy.exc import IntegrityError
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy.orm import selectinload
-
-from backend.app.api.routes._spoolman_helpers import (
-    NormalizedFilament,
-    NormalizedVendorRef,
-    _map_spoolman_spool,
-    _safe_float,
-    _safe_int,
-    _safe_optional_float,
-    assert_safe_spoolman_url,
-)
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
-from backend.app.core.database import get_db
-from backend.app.core.permissions import Permission
-from backend.app.models.ams_label import AmsLabel
-from backend.app.models.printer import Printer
-from backend.app.models.settings import Settings
-from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-from backend.app.models.user import User
-from backend.app.schemas.spool import SpoolKProfileBase
-from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
-from backend.app.services.printer_manager import printer_manager
-from backend.app.services.spoolman import (
-    SpoolmanClient,
-    SpoolmanClientError,
-    SpoolmanNotFoundError,
-    SpoolmanUnavailableError,
-    get_spoolman_client,
-    init_spoolman_client,
-)
-from backend.app.utils.filament_ids import (
-    GENERIC_FILAMENT_IDS,
-    MATERIAL_TEMPS,
-    normalize_slicer_filament,
-)
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(prefix="/spoolman/inventory", tags=["spoolman-inventory"])
-
-
-# Cache the last successful health-check timestamp to avoid a round-trip on
-# every request.  A failed check clears the cache immediately.
-_health_check_cache: dict[str, float] = {}
-_HEALTH_CHECK_TTL = 30.0  # seconds
-
-
-def _tag_cleared(val: str | None) -> bool:
-    """Return True when a PATCH field explicitly removes a tag (null)."""
-    return val is None
-
-
-async def _get_client(db: AsyncSession) -> SpoolmanClient:
-    """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
-    result = await db.execute(select(Settings))
-    settings: dict[str, str] = {s.key: s.value for s in result.scalars().all()}
-
-    enabled = settings.get("spoolman_enabled", "false").lower() == "true"
-    url = settings.get("spoolman_url", "").strip()
-
-    if not enabled:
-        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
-    if not url:
-        raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
-
-    # SSRF guard: reject dangerous schemes, cloud-metadata IPs (169.254.169.254, 100.100.100.200,
-    # fd00:ec2::254), multicast and unspecified addresses — loopback and RFC-1918 ranges are
-    # intentionally permitted (Spoolman commonly runs on the same host or home LAN).
-    # Raises ValueError with a descriptive message on any violation.
-    try:
-        assert_safe_spoolman_url(url)
-    except ValueError as exc:
-        raise HTTPException(status_code=400, detail=str(exc)) from exc
-
-    # Re-use the cached client when URL is unchanged; reinitialise on URL change (cache invalidation).
-    client = await get_spoolman_client()
-    if not client or client.base_url != url.rstrip("/"):
-        try:
-            client = await init_spoolman_client(url)
-        except ValueError as exc:
-            raise HTTPException(status_code=400, detail=str(exc)) from exc
-
-    # Only call health_check() when the cached result has expired.
-    # Evict stale entries when URL changes (only one Spoolman URL is active at a time).
-    if url not in _health_check_cache and _health_check_cache:
-        _health_check_cache.clear()
-    now = time.monotonic()
-    last_ok = _health_check_cache.get(url, 0.0)
-    if now - last_ok > _HEALTH_CHECK_TTL:
-        if not await client.health_check():
-            _health_check_cache.pop(url, None)
-            raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
-        _health_check_cache[url] = now
-
-    return client
-
-
-@asynccontextmanager
-async def _translate_spoolman_errors():
-    """Translate Spoolman typed exceptions to HTTP errors for all inventory endpoints."""
-    try:
-        yield
-    except SpoolmanNotFoundError as exc:
-        raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
-    except SpoolmanClientError as exc:
-        raise HTTPException(
-            status_code=502,
-            detail={
-                "message": "Spoolman rejected the request",
-                "upstream_status": exc.status_code,
-                "upstream_body": getattr(exc, "response_text", ""),
-            },
-        ) from exc
-    except SpoolmanUnavailableError as exc:
-        raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
-
-
-def _raise_if_partial_failure(spools: list[dict], results: list, operation: str) -> None:
-    """Raise HTTP 502 if any gather result is an exception, logging each failure."""
-    failures = [(s["id"], r) for s, r in zip(spools, results, strict=True) if isinstance(r, BaseException)]
-    if failures:
-        logger.error(
-            "Partial %s failure: %d/%d spools failed: %s",
-            operation,
-            len(failures),
-            len(spools),
-            [(sid, type(exc).__name__) for sid, exc in failures],
-        )
-        raise HTTPException(
-            status_code=502,
-            detail=f"{operation} partially applied: {len(spools) - len(failures)}/{len(spools)} spools updated",
-        )
-
-
-async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> tuple[dict, list[str]]:
-    """Patch the spool price; return (updated_spool, warnings).
-
-    Returns the original spool and a non-empty warnings list when the price
-    update fails, so the caller can return HTTP 207 instead of silently
-    discarding the price.
-    """
-    if cost_per_kg is None:
-        return spool, []
-    try:
-        async with _translate_spoolman_errors():
-            updated = await client.update_spool_full(spool["id"], price=cost_per_kg)
-        return updated, []
-    except HTTPException as exc:
-        if exc.status_code >= 500:
-            raise  # Propagate network/server errors — don't swallow Spoolman outages
-        logger.warning(
-            "Price update failed for spool %d; spool created without price (cost_per_kg=%s, status=%d)",
-            spool["id"],
-            cost_per_kg,
-            exc.status_code,
-        )
-        return spool, [f"price_not_set: Spoolman rejected the price update (HTTP {exc.status_code})"]
-
-
-# ---------------------------------------------------------------------------
-# Request / response schemas
-# ---------------------------------------------------------------------------
-
-
-_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$")
-
-
-def _validate_rgba(v: str | None) -> str | None:
-    if v is None:
-        return v
-    clean = v.removeprefix("#")
-    if not _HEX_RE.match(clean):
-        raise ValueError("rgba must be a 6 or 8 character hex string (RRGGBB or RRGGBBAA)")
-    return clean.upper()
-
-
-def _validate_storage_location(v: str | None) -> str | None:
-    if v is not None and any(c in v for c in ("\r", "\n", "\x00")):
-        raise ValueError("storage_location must not contain control characters")
-    return v
-
-
-class SpoolmanInventoryCreate(BaseModel):
-    # When spoolman_filament_id is provided the caller has already chosen a filament from the
-    # Spoolman catalog, so material (and other metadata) are optional — the backend skips
-    # find_or_create_filament() and uses the supplied ID directly.
-    spoolman_filament_id: int | None = Field(None, gt=0)
-    material: str | None = Field(None, min_length=1, max_length=64)
-    subtype: str | None = Field(None, max_length=64)
-    brand: str | None = Field(None, max_length=128)
-    color_name: str | None = Field(None, max_length=64)
-    rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
-    label_weight: int = Field(1000, ge=1, le=100_000)
-    core_weight: int = Field(
-        250, ge=0, le=10_000
-    )  # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
-    weight_used: float = Field(0.0, ge=0.0, le=100_000.0)
-    note: str | None = Field(None, max_length=1000)
-    cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
-    storage_location: str | None = Field(None, max_length=255)
-    # BambuStudio slicer preset for this spool. Spoolman has no native field
-    # for this, so we persist it under the bambu_slicer_filament[_name] keys
-    # in the spool's extra dict and read it back in _map_spoolman_spool.
-    slicer_filament: str | None = Field(None, max_length=128)
-    slicer_filament_name: str | None = Field(None, max_length=255)
-
-    @field_validator("rgba")
-    @classmethod
-    def validate_rgba(cls, v: str | None) -> str | None:
-        return _validate_rgba(v)
-
-    @field_validator("storage_location")
-    @classmethod
-    def validate_storage_location(cls, v: str | None) -> str | None:
-        return _validate_storage_location(v)
-
-    @model_validator(mode="after")
-    def validate_weight_consistency(self) -> SpoolmanInventoryCreate:
-        # material is required only when the caller has not pre-selected a Spoolman filament
-        if self.spoolman_filament_id is None and not self.material:
-            raise ValueError("material is required when spoolman_filament_id is not provided")
-        if self.weight_used > self.label_weight:
-            raise ValueError("weight_used must not exceed label_weight")
-        return self
-
-
-class SpoolmanInventoryUpdate(BaseModel):
-    material: str | None = Field(None, min_length=1, max_length=64)
-    subtype: str | None = Field(None, max_length=64)
-    brand: str | None = Field(None, max_length=128)
-    color_name: str | None = Field(None, max_length=64)
-    rgba: str | None = Field(None, max_length=8, description="6-digit hex (RRGGBB) or 8-digit (RRGGBBAA)")
-    label_weight: int | None = Field(None, ge=1, le=100_000)
-    core_weight: int | None = Field(
-        None, ge=0, le=10_000
-    )  # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool)
-    weight_used: float | None = Field(None, ge=0.0, le=100_000.0)
-    note: str | None = Field(None, max_length=1000)
-    cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
-    tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
-    tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
-    storage_location: str | None = Field(None, max_length=255)
-    # BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
-    # schema). Pass an empty string to clear; null/omitted leaves unchanged.
-    slicer_filament: str | None = Field(None, max_length=128)
-    slicer_filament_name: str | None = Field(None, max_length=255)
-
-    @field_validator("rgba")
-    @classmethod
-    def validate_rgba(cls, v: str | None) -> str | None:
-        return _validate_rgba(v)
-
-    @field_validator("storage_location")
-    @classmethod
-    def validate_storage_location(cls, v: str | None) -> str | None:
-        return _validate_storage_location(v)
-
-    @model_validator(mode="after")
-    def validate_tag_fields(self) -> SpoolmanInventoryUpdate:
-        # null = remove tag; non-null values rejected (use /tag endpoint to write tags)
-        if self.tag_uid is not None:
-            raise ValueError("tag_uid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
-        if self.tray_uuid is not None:
-            raise ValueError("tray_uuid cannot be set via this endpoint; use PATCH /spools/{id}/tag to write tags")
-        return self
-
-    @model_validator(mode="after")
-    def validate_weight_consistency(self) -> SpoolmanInventoryUpdate:
-        if self.weight_used is not None and self.label_weight is not None:
-            if self.weight_used > self.label_weight:
-                raise ValueError("weight_used must not exceed label_weight")
-        return self
-
-
-class SpoolmanInventoryBulkCreate(BaseModel):
-    spool: SpoolmanInventoryCreate
-    quantity: int = Field(1, ge=1, le=50)
-
-
-class SpoolWeightUpdate(BaseModel):
-    weight_grams: float = Field(..., ge=0.0, le=100_000.0)
-
-
-class SpoolTagLinkRequest(BaseModel):
-    # Minimum 8 hex chars = 4-byte NFC UID (Bambu Lab hardware tags use 4-byte UIDs).
-    tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
-    tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
-
-    @field_validator("tag_uid")
-    @classmethod
-    def tag_uid_not_all_zeros(cls, v: str | None) -> str | None:
-        if v is not None and all(c in "0" for c in v):
-            raise ValueError("tag_uid must not be all-zero bytes")
-        return v
-
-    @model_validator(mode="after")
-    def at_least_one(self) -> SpoolTagLinkRequest:
-        if not self.tag_uid and not self.tray_uuid:
-            raise ValueError("tag_uid or tray_uuid is required")
-        return self
-
-
-class SpoolSlotAssignmentRequest(BaseModel):
-    spoolman_spool_id: int = Field(..., gt=0)
-    printer_id: int = Field(..., gt=0)
-    # ams_id 0–7 for physical AMS units; 255 = external/virtual spool extruder slot
-    ams_id: int = Field(..., ge=0, le=255)
-    tray_id: int = Field(..., ge=0, le=3)
-
-
-# ---------------------------------------------------------------------------
-# Endpoints
-# ---------------------------------------------------------------------------
-
-
-@router.get("/spools")
-async def list_spools(
-    include_archived: bool = Query(False),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
-) -> list[dict]:
-    """Return all Spoolman spools in the InventorySpool format."""
-    client = await _get_client(db)
-    async with _translate_spoolman_errors():
-        spools = await client.get_all_spools(allow_archived=include_archived)
-
-    mapped: list[dict] = []
-    spool_ids: list[int] = []
-    for s in spools:
-        try:
-            m = _map_spoolman_spool(s)
-            mapped.append(m)
-            spool_ids.append(m["id"])
-        except ValueError as exc:
-            logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc)
-
-    if spool_ids:
-        kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id.in_(spool_ids)))
-        kp_by_spool: dict[int, list[dict]] = {}
-        for kp in kp_result.scalars().all():
-            kp_by_spool.setdefault(kp.spoolman_spool_id, []).append(_k_profile_to_dict(kp))
-        for m in mapped:
-            m["k_profiles"] = kp_by_spool.get(m["id"], [])
-
-    return mapped
-
-
-@router.get("/spools/{spool_id}")
-async def get_spool(
-    spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
-) -> dict:
-    """Return a single Spoolman spool in the InventorySpool format."""
-    client = await _get_client(db)
-    async with _translate_spoolman_errors():
-        spool = await client.get_spool(spool_id)
-    try:
-        mapped = _map_spoolman_spool(spool)
-    except ValueError as exc:
-        logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
-        raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
-
-    kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
-    mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
-    return mapped
-
-
-async def _resolve_filament_id(data: SpoolmanInventoryCreate, client: SpoolmanClient) -> int:
-    """Return the Spoolman filament ID for this spool creation request.
-
-    If spoolman_filament_id is set the caller pre-selected a catalog entry,
-    so find_or_create_filament() is skipped and the ID is used directly.
-    """
-    if data.spoolman_filament_id is not None:
-        return data.spoolman_filament_id
-    # Validator guarantees material is non-None when spoolman_filament_id is None
-    assert data.material is not None  # noqa: S101
-    color_hex = (data.rgba or "808080FF")[:6]
-    async with _translate_spoolman_errors():
-        return await client.find_or_create_filament(
-            material=data.material,
-            subtype=data.subtype or "",
-            brand=data.brand,
-            color_hex=color_hex,
-            label_weight=data.label_weight,
-            color_name=data.color_name,
-        )
-
-
-@router.post("/spools")
-async def create_spool(
-    data: SpoolmanInventoryCreate,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Create a new spool in Spoolman, auto-creating vendor and filament as needed."""
-    client = await _get_client(db)
-    filament_id = await _resolve_filament_id(data, client)
-
-    remaining = max(0.0, data.label_weight - data.weight_used)
-    try:
-        async with _translate_spoolman_errors():
-            spool = await client.create_spool(
-                filament_id=filament_id,
-                remaining_weight=remaining,
-                comment=data.note or None,
-                location=data.storage_location or None,
-            )
-    except HTTPException as exc:
-        if exc.status_code == 404 and data.spoolman_filament_id is not None:
-            raise HTTPException(
-                status_code=404,
-                detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
-            ) from exc
-        raise
-
-    spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
-
-    # Persist slicer_filament under the spool's extra dict (mirror update_spool).
-    if data.slicer_filament is not None or data.slicer_filament_name is not None:
-        # Ensure extra fields are registered before write.
-        if data.slicer_filament is not None:
-            await client.ensure_extra_field("bambu_slicer_filament")
-        if data.slicer_filament_name is not None:
-            await client.ensure_extra_field("bambu_slicer_filament_name")
-        new_extra: dict = {}
-        if data.slicer_filament is not None:
-            new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament)
-        if data.slicer_filament_name is not None:
-            new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name)
-        if new_extra:
-            try:
-                async with _translate_spoolman_errors():
-                    spool = await client.merge_spool_extra(spool["id"], new_extra)
-            except HTTPException:
-                # Best-effort — the spool already exists, log and continue.
-                logger.warning(
-                    "Failed to persist slicer_filament for spool %s",
-                    spool.get("id"),
-                )
-
-    result = _map_spoolman_spool(spool)
-    if price_warnings:
-        return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
-    return result
-
-
-@router.post("/spools/bulk")
-async def bulk_create_spools(
-    payload: SpoolmanInventoryBulkCreate,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> Response:
-    """Create multiple identical spools in Spoolman."""
-    client = await _get_client(db)
-    data = payload.spool
-
-    try:
-        filament_id = await _resolve_filament_id(data, client)
-    except HTTPException as exc:
-        if exc.status_code == 404 and data.spoolman_filament_id is not None:
-            raise HTTPException(
-                status_code=404,
-                detail=f"Filament {data.spoolman_filament_id} not found in Spoolman",
-            ) from exc
-        raise
-
-    remaining = max(0.0, data.label_weight - data.weight_used)
-    created: list[dict] = []
-    failures: list[str] = []
-    for _ in range(payload.quantity):
-        try:
-            spool = await client.create_spool(
-                filament_id=filament_id,
-                remaining_weight=remaining,
-                comment=data.note or None,
-                location=data.storage_location or None,
-            )
-        except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
-            logger.warning("Bulk spool creation: one spool failed: %s", exc)
-            failures.append("spool creation failed")
-            continue
-        try:
-            spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
-        except HTTPException as exc:
-            logger.warning(
-                "Bulk spool %d: price update failed (HTTP %d); spool not added to created list",
-                spool.get("id", 0),
-                exc.status_code,
-            )
-            failures.append("spool created but price update failed")
-            continue
-        if price_warnings:
-            logger.warning("Bulk spool %s created without price: %s", spool.get("id"), price_warnings)
-        created.append(_map_spoolman_spool(spool))
-
-    if not created:
-        raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
-
-    if len(created) < payload.quantity:
-        # Some spool creations failed — return 207 Multi-Status so the caller
-        # can distinguish a full success from a partial one and show a useful message.
-        return JSONResponse(
-            status_code=207,
-            content={
-                "created": created,
-                "requested_count": payload.quantity,
-                "failed_count": payload.quantity - len(created),
-                "failures": failures,
-            },
-        )
-
-    return JSONResponse(status_code=200, content=created)
-
-
-@router.patch("/spools/{spool_id}")
-async def update_spool(
-    *,
-    spool_id: int = Path(..., gt=0),
-    data: SpoolmanInventoryUpdate,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Update an existing Spoolman spool, re-linking the filament if metadata changed."""
-    client = await _get_client(db)
-
-    async with _translate_spoolman_errors():
-        current = await client.get_spool(spool_id)
-
-    cur_filament: dict = current.get("filament") or {}
-    cur_vendor: dict = cur_filament.get("vendor") or {}
-    cur_mat: str = (cur_filament.get("material") or "").strip()
-    cur_name: str = (cur_filament.get("name") or "").strip()
-    if cur_mat and cur_name.upper().startswith(cur_mat.upper()):
-        cur_subtype: str = cur_name[len(cur_mat) :].strip()
-    else:
-        cur_subtype = cur_name
-
-    # Resolve final values: use request value if provided, else keep current
-    material = data.material if data.material is not None else cur_mat
-    subtype = data.subtype if data.subtype is not None else cur_subtype
-    brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None)
-    color_name = data.color_name if data.color_name is not None else (cur_filament.get("color_name") or None)
-    cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
-    rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
-    label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
-    weight_used = data.weight_used if data.weight_used is not None else float(current.get("used_weight") or 0)
-    note = data.note if data.note is not None else current.get("comment")
-    storage_location_changed = "storage_location" in data.model_fields_set
-    storage_location = data.storage_location if storage_location_changed else None
-
-    color_hex = rgba[:6]
-    async with _translate_spoolman_errors():
-        filament_id = await client.find_or_create_filament(
-            material=material,
-            subtype=subtype or "",
-            brand=brand,
-            color_hex=color_hex,
-            label_weight=label_weight,
-            color_name=color_name,
-        )
-    if not filament_id:
-        raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
-
-    remaining = max(0.0, label_weight - weight_used)
-
-    # Tag removal: clear only the "tag" key so other custom Spoolman extra fields
-    # set outside Bambuddy are preserved.
-    tag_nulled = (
-        ("tag_uid" in data.model_fields_set or "tray_uuid" in data.model_fields_set)
-        and _tag_cleared(data.tag_uid)
-        and _tag_cleared(data.tray_uuid)
-    )
-
-    # Serialise tag-clear + PATCH under the per-spool extra lock to prevent a
-    # concurrent merge_spool_extra call (e.g. NFC write-back) from overwriting
-    # the tag key between our read and our write.
-    #
-    # Spoolman PATCHes extra dicts by MERGING — popping "tag" from a re-fetched
-    # dict and sending the rest doesn't clear the key (Spoolman keeps the old
-    # value because the key wasn't in the payload). Explicitly set the tag to
-    # a JSON-encoded empty string; read-side filters strip the quotes.
-    async with client.extra_lock(spool_id):
-        if tag_nulled:
-            # Re-fetch inside the lock so we work with fresh extra data.
-            async with _translate_spoolman_errors():
-                fresh = await client.get_spool(spool_id)
-            cur_extra = dict(fresh.get("extra") or {})
-            cur_extra["tag"] = json.dumps("")
-            extra: dict | None = cur_extra
-        else:
-            extra = None
-
-        async with _translate_spoolman_errors():
-            updated = await client.update_spool_full(
-                spool_id=spool_id,
-                filament_id=filament_id,
-                remaining_weight=remaining,
-                comment=note or "",
-                price=data.cost_per_kg,
-                extra=extra,
-                location=storage_location or None,
-                clear_location=storage_location_changed and not storage_location,
-            )
-
-    # Persist BambuStudio slicer preset under the spool's extra dict.
-    # Spoolman doesn't have a native field for this, so we round-trip via
-    # extra and unpack in _map_spoolman_spool. Only writes when the request
-    # explicitly set the field — passing null/omitting leaves the existing
-    # extra entry untouched (write empty string to clear).
-    sf_set = "slicer_filament" in data.model_fields_set
-    sfn_set = "slicer_filament_name" in data.model_fields_set
-    if sf_set or sfn_set:
-        # Ensure extra fields are registered (Spoolman rejects PATCHes with
-        # unknown keys with HTTP 400). Idempotent if startup already ran this.
-        if sf_set:
-            await client.ensure_extra_field("bambu_slicer_filament")
-        if sfn_set:
-            await client.ensure_extra_field("bambu_slicer_filament_name")
-        new_extra: dict = {}
-        if sf_set:
-            new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament or "")
-        if sfn_set:
-            new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
-        async with _translate_spoolman_errors():
-            updated = await client.merge_spool_extra(spool_id, new_extra)
-
-    return _map_spoolman_spool(updated)
-
-
-@router.delete("/spools/{spool_id}")
-async def delete_spool(
-    spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Permanently delete a spool from Spoolman."""
-    client = await _get_client(db)
-    async with _translate_spoolman_errors():
-        await client.delete_spool(spool_id)
-    return {"status": "deleted"}
-
-
-@router.post("/spools/{spool_id}/archive")
-async def archive_spool(
-    spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Archive a spool in Spoolman (soft-delete)."""
-    client = await _get_client(db)
-    async with _translate_spoolman_errors():
-        spool = await client.set_spool_archived(spool_id, archived=True)
-    try:
-        return _map_spoolman_spool(spool)
-    except ValueError as exc:
-        logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
-        raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
-
-
-@router.post("/spools/{spool_id}/restore")
-async def restore_spool(
-    spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Restore an archived spool in Spoolman."""
-    client = await _get_client(db)
-    async with _translate_spoolman_errors():
-        spool = await client.set_spool_archived(spool_id, archived=False)
-    try:
-        return _map_spoolman_spool(spool)
-    except ValueError as exc:
-        logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
-        raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
-
-
-@router.patch("/spools/{spool_id}/weight")
-async def sync_spool_weight(
-    *,
-    spool_id: int = Path(..., gt=0),
-    data: SpoolWeightUpdate,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Update a spool's remaining weight from a measured gross weight.
-
-    Computes remaining = gross_weight - tare, where tare = spool.spool_weight
-    if set, else filament.spool_weight; falls back to 250 g when both unset.
-    """
-    client = await _get_client(db)
-
-    async with _translate_spoolman_errors():
-        current = await client.get_spool(spool_id)
-
-    cur_filament = current.get("filament") or {}
-    spool_tare = current.get("spool_weight")
-    raw_tare = spool_tare if spool_tare is not None else cur_filament.get("spool_weight")
-    core_weight = _safe_float(raw_tare, 250.0)
-    remaining = max(0.0, data.weight_grams - core_weight)
-
-    async with _translate_spoolman_errors():
-        updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining)
-
-    upd_filament = updated.get("filament") or {}
-    label_weight = _safe_int(upd_filament.get("weight"), 1000)
-    weight_used = max(0.0, label_weight - remaining)
-    return {"status": "ok", "weight_used": weight_used}
-
-
-@router.patch("/spools/{spool_id}/tag")
-async def link_tag_to_spoolman_spool(
-    *,
-    spool_id: int = Path(..., gt=0),
-    data: SpoolTagLinkRequest,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
-
-    tray_uuid takes precedence over tag_uid when both are supplied.
-    Returns 409 if another spool already carries the same tag.
-    Uses extra_lock to serialise against concurrent extra-field writes.
-    """
-    client = await _get_client(db)
-    tag = (data.tray_uuid or data.tag_uid).upper()
-    tag_json = json.dumps(tag)
-
-    async with client.extra_lock(spool_id):
-        # Duplicate check: scan all spools for the same tag on a different spool.
-        async with _translate_spoolman_errors():
-            all_spools = await client.get_all_spools()
-        for s in all_spools:
-            s_tag = (s.get("extra") or {}).get("tag", "")
-            if s_tag.strip('"').upper() == tag and s.get("id") != spool_id:
-                raise HTTPException(
-                    status_code=409,
-                    detail=f"Tag is already assigned to spool {s['id']}",
-                )
-
-        # Re-fetch inside the lock so cur_extra reflects any concurrent update.
-        async with _translate_spoolman_errors():
-            current = await client.get_spool(spool_id)
-        cur_extra = dict(current.get("extra") or {})
-        cur_extra["tag"] = tag_json
-        async with _translate_spoolman_errors():
-            updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
-
-    logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
-    return _map_spoolman_spool(updated)
-
-
-@router.get("/slot-assignments/all", response_model=list[SpoolmanSlotAssignmentEnriched])
-async def get_all_spoolman_slot_assignments(
-    printer_id: int | None = Query(None, gt=0),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
-) -> list[SpoolmanSlotAssignmentEnriched]:
-    """Return all Spoolman slot assignments enriched with printer_name and ams_label.
-
-    ``printer_name`` is null only when the printer relation is missing
-    (cascade-deleted edge case). ``ams_label`` is null when no AmsLabel row
-    matches the slot's MQTT serial (or the synthetic ``f"p{pid}a{ams_id}"``
-    fallback key).
-    """
-    query = select(SpoolmanSlotAssignment).options(selectinload(SpoolmanSlotAssignment.printer))
-    if printer_id is not None:
-        query = query.where(SpoolmanSlotAssignment.printer_id == printer_id)
-    result = await db.execute(query)
-    slots = list(result.scalars().all())
-
-    # Build (printer_id, ams_id) -> ams_serial map from live printer states.
-    # Same pattern as inventory.py:765-806 for the local /assignments endpoint.
-    printer_ids = {s.printer_id for s in slots}
-    serial_map: dict[tuple[int, int], str] = {}
-    all_statuses = printer_manager.get_all_statuses()
-    for pid in printer_ids:
-        state = all_statuses.get(pid)
-        if not (state and state.raw_data):
-            continue
-        # Some printer firmware variants wrap the AMS list in an outer dict
-        # (`{"ams": [...]}`). Mirror the defense used in sync_spoolman_ams_weights
-        # (line 842-844) so a wrapped payload still resolves to a list.
-        ams_raw = state.raw_data.get("ams", [])
-        if isinstance(ams_raw, dict):
-            ams_raw = ams_raw.get("ams", [])
-        if not isinstance(ams_raw, list):
-            continue
-        for ams_unit in ams_raw:
-            if not isinstance(ams_unit, dict):
-                continue
-            sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
-            if not sn:
-                continue
-            try:
-                serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
-            except (ValueError, TypeError):
-                continue
-
-    # Add synthetic fallback key (f"p{pid}a{ams_id}") for slots without a serial.
-    all_serials: set[str] = set(serial_map.values())
-    for s in slots:
-        if (s.printer_id, s.ams_id) not in serial_map:
-            all_serials.add(f"p{s.printer_id}a{s.ams_id}")
-
-    label_by_serial: dict[str, str] = {}
-    if all_serials:
-        lbl_result = await db.execute(select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials)))
-        for lbl in lbl_result.scalars().all():
-            label_by_serial[lbl.ams_serial_number] = lbl.label
-
-    def _ams_label_for(pid: int, ams_id: int) -> str | None:
-        sn = serial_map.get((pid, ams_id))
-        if sn and sn in label_by_serial:
-            return label_by_serial[sn]
-        if not sn:
-            return label_by_serial.get(f"p{pid}a{ams_id}")
-        return None
-
-    enriched: list[SpoolmanSlotAssignmentEnriched] = []
-    for s in slots:
-        if s.printer is None:
-            # FK is ondelete=CASCADE so this should be unreachable in normal
-            # operation; surface it loudly if a stale row ever appears.
-            logger.warning(
-                "Orphaned Spoolman slot assignment: printer_id=%d (ams=%d, tray=%d, spoolman_spool_id=%d) has no Printer row",
-                s.printer_id,
-                s.ams_id,
-                s.tray_id,
-                s.spoolman_spool_id,
-            )
-        enriched.append(
-            SpoolmanSlotAssignmentEnriched(
-                printer_id=s.printer_id,
-                printer_name=s.printer.name if s.printer else None,
-                ams_id=s.ams_id,
-                tray_id=s.tray_id,
-                spoolman_spool_id=s.spoolman_spool_id,
-                ams_label=_ams_label_for(s.printer_id, s.ams_id),
-            )
-        )
-    return enriched
-
-
-@router.post("/sync-ams-weights")
-async def sync_spoolman_ams_weights(
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-):
-    """Sync remaining weight back to Spoolman for all slot-assigned spools.
-
-    Reads live AMS remain% from connected printers, computes
-    remaining = label_weight * remain% / 100, and PATCHes Spoolman.
-    """
-    client = await _get_client(db)
-
-    # Fetch all non-archived Spoolman spools once for label_weight lookup
-    async with _translate_spoolman_errors():
-        raw_spools = await client.get_all_spools(allow_archived=False)
-    spool_lookup: dict[int, dict] = {s["id"]: s for s in raw_spools if s.get("id") is not None}
-
-    result = await db.execute(select(SpoolmanSlotAssignment))
-    assignments = list(result.scalars().all())
-
-    synced = 0
-    skipped = 0
-
-    def _find_tray(ams_data: list, ams_id: int, tray_id: int) -> dict | None:
-        if not ams_data:
-            return None
-        for ams_unit in ams_data:
-            if _safe_int(ams_unit.get("id"), -1) != ams_id:
-                continue
-            for tray in ams_unit.get("tray", []):
-                if _safe_int(tray.get("id"), -1) == tray_id:
-                    return tray
-        return None
-
-    for assignment in assignments:
-        spool_dict = spool_lookup.get(assignment.spoolman_spool_id)
-        if not spool_dict:
-            logger.debug("Spoolman AMS sync: spool %d not found in Spoolman, skipping", assignment.spoolman_spool_id)
-            skipped += 1
-            continue
-
-        label_weight = _safe_int((spool_dict.get("filament") or {}).get("weight"), 1000)
-        if label_weight <= 0:
-            logger.debug("Spoolman AMS sync: spool %d has no label_weight, skipping", assignment.spoolman_spool_id)
-            skipped += 1
-            continue
-
-        state = printer_manager.get_status(assignment.printer_id)
-        if not state or not state.raw_data:
-            logger.info(
-                "Spoolman AMS sync: printer %d not connected, skipping spool %d",
-                assignment.printer_id,
-                assignment.spoolman_spool_id,
-            )
-            skipped += 1
-            continue
-
-        ams_raw = state.raw_data.get("ams", [])
-        if isinstance(ams_raw, dict):
-            ams_raw = ams_raw.get("ams", [])
-        tray = _find_tray(ams_raw, assignment.ams_id, assignment.tray_id)
-        if not tray:
-            logger.info(
-                "Spoolman AMS sync: no tray data for spool %d (printer %d AMS%d-T%d)",
-                assignment.spoolman_spool_id,
-                assignment.printer_id,
-                assignment.ams_id,
-                assignment.tray_id,
-            )
-            skipped += 1
-            continue
-
-        remain_raw = tray.get("remain")
-        if remain_raw is None:
-            logger.debug(
-                "Spoolman AMS sync: no remain value for spool %d (tray %d/%d), skipping",
-                assignment.spoolman_spool_id,
-                assignment.ams_id,
-                assignment.tray_id,
-            )
-            skipped += 1
-            continue
-
-        try:
-            remain_val = int(remain_raw)
-        except (TypeError, ValueError):
-            logger.debug(
-                "Spoolman AMS sync: non-numeric remain=%r for spool %d, skipping",
-                remain_raw,
-                assignment.spoolman_spool_id,
-            )
-            skipped += 1
-            continue
-
-        if remain_val < 0 or remain_val > 100:
-            logger.debug("Spoolman AMS sync: invalid remain=%s for spool %d", remain_raw, assignment.spoolman_spool_id)
-            skipped += 1
-            continue
-
-        remaining = round(label_weight * remain_val / 100.0, 1)
-        try:
-            async with _translate_spoolman_errors():
-                await client.update_spool_full(assignment.spoolman_spool_id, remaining_weight=remaining)
-            logger.info(
-                "Spoolman AMS sync: spool %d remaining set to %s g (remain=%d%%)",
-                assignment.spoolman_spool_id,
-                remaining,
-                remain_val,
-            )
-            synced += 1
-        except HTTPException as exc:
-            if exc.status_code == 404:
-                logger.warning(
-                    "Spoolman AMS sync: spool %d not found in Spoolman (404), skipping",
-                    assignment.spoolman_spool_id,
-                )
-            else:
-                logger.warning(
-                    "Spoolman AMS sync: failed to update spool %d (HTTP %d)",
-                    assignment.spoolman_spool_id,
-                    exc.status_code,
-                )
-            skipped += 1
-
-    return {"synced": synced, "skipped": skipped}
-
-
-@router.post("/slot-assignments")
-async def assign_spoolman_slot(
-    body: SpoolSlotAssignmentRequest,
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
-
-    Raises 404 if the printer does not exist or the spool is not found in Spoolman.
-    Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
-    """
-
-    client = await _get_client(db)
-    result = await db.execute(select(Printer).where(Printer.id == body.printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(status_code=404, detail="Printer not found")
-
-    # Verify the Spoolman spool exists before committing to local DB.
-    # This prevents ghost rows pointing at non-existent spool IDs.
-    async with _translate_spoolman_errors():
-        spool = await client.get_spool(body.spoolman_spool_id)
-
-    # Spool confirmed in Spoolman — upsert into local slot-assignment table
-    # assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)
-    try:
-        await db.execute(
-            text(
-                "INSERT INTO spoolman_slot_assignments"
-                " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                " ON CONFLICT(printer_id, ams_id, tray_id)"
-                " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-            ),
-            {
-                "printer_id": body.printer_id,
-                "ams_id": body.ams_id,
-                "tray_id": body.tray_id,
-                "spool_id": body.spoolman_spool_id,
-            },
-        )
-        await db.commit()
-    except Exception as exc:
-        await db.rollback()
-        logger.error("Failed to persist slot assignment: %s", exc)
-        raise HTTPException(status_code=500, detail="Failed to save slot assignment") from exc
-
-    mapped = _map_spoolman_spool(spool)
-
-    # Fetch K-profiles before the MQTT try block so we can use async DB access.
-    kp_rows_result = await db.execute(
-        select(SpoolmanKProfile).where(
-            SpoolmanKProfile.spoolman_spool_id == body.spoolman_spool_id,
-            SpoolmanKProfile.printer_id == body.printer_id,
-        )
-    )
-    kp_rows = kp_rows_result.scalars().all()
-
-    # Auto-configure AMS slot via MQTT (best-effort; slot assignment is already persisted)
-    try:
-        mqtt_client = printer_manager.get_client(body.printer_id)
-        if mqtt_client:
-            tray_type = mapped.get("material") or ""
-            brand = mapped.get("brand") or ""
-            subtype = mapped.get("subtype") or ""
-            if brand:
-                tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
-            elif subtype:
-                tray_sub_brands = f"{tray_type} {subtype}".strip()
-            else:
-                tray_sub_brands = tray_type
-
-            tray_color = (mapped.get("rgba") or "808080FF").upper()
-            if len(tray_color) == 6:
-                tray_color = tray_color + "FF"
-
-            material_upper = tray_type.upper().strip()
-            tray_info_idx = (
-                GENERIC_FILAMENT_IDS.get(material_upper)
-                or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
-                or ""
-            )
-            setting_id = ""
-
-            temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
-            temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
-            temp_max = temp_defaults[1]
-
-            # Pull printer state from printer_manager. The previous
-            # `mqtt_client.printer_state` access via hasattr always returned
-            # None (the attribute is `state`, not `printer_state`), so the
-            # K-profile cascade silently skipped state.kprofiles, defaulted
-            # nozzle_diameter to 0.4, and left slot_extruder unset.
-            state = printer_manager.get_status(body.printer_id)
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            slot_extruder = None
-            if state and state.ams_extruder_map:
-                if body.ams_id == 255:
-                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                    # tray_id 0→1, 1→0
-                    slot_extruder = 1 - body.tray_id
-                else:
-                    slot_extruder = state.ams_extruder_map.get(str(body.ams_id))
-
-            # Prefer exact extruder match, fall back to extruder-agnostic kp
-            # for the same nozzle. Hard-skipping on mismatch silently dropped
-            # valid stored profiles when the AMS-extruder mapping had shifted.
-            exact_kp = None
-            fallback_kp = None
-            for kp in kp_rows:
-                if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
-                    continue
-                if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
-                    exact_kp = kp
-                    break
-                if fallback_kp is None:
-                    fallback_kp = kp
-            matching_kp = exact_kp or fallback_kp
-
-            # Resolve the printer-side calibration entry by cali_idx so we
-            # know the authoritative filament_id (the printer indexes its
-            # calibration table by filament_id, not setting_id).
-            printer_kp = None
-            if matching_kp and state and state.kprofiles:
-                for pkp in state.kprofiles:
-                    if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
-                        printer_kp = pkp
-                        break
-                if printer_kp is None:
-                    logger.warning(
-                        "Spoolman assign: cali_idx=%d not present in printer's "
-                        "calibration table — stored kp may be stale.",
-                        matching_kp.cali_idx,
-                    )
-
-            # Realign the slot's filament context (tray_info_idx + setting_id)
-            # to the kp's calibration context. Without this, ams_filament_setting
-            # declares the slot under generic PLA while extrusion_cali_sel points
-            # the cali_idx at a different preset — the printer can't link them
-            # and falls back to the default profile. P-prefix local presets are
-            # valid for tray_info_idx; PFUS-prefix cloud-user presets are not
-            # (the slicer rejects them).
-            effective_tray_info_idx = tray_info_idx
-            effective_setting_id = setting_id
-            if printer_kp and printer_kp.filament_id:
-                if not printer_kp.filament_id.startswith("PFUS"):
-                    effective_tray_info_idx = printer_kp.filament_id
-                if printer_kp.setting_id:
-                    effective_setting_id = printer_kp.setting_id
-            elif matching_kp and matching_kp.setting_id:
-                derived = normalize_slicer_filament(matching_kp.setting_id)[0]
-                if derived and not derived.startswith("PFUS"):
-                    effective_tray_info_idx = derived
-                effective_setting_id = matching_kp.setting_id
-            if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
-                logger.info(
-                    "Spoolman assign: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
-                    tray_info_idx,
-                    effective_tray_info_idx,
-                    setting_id,
-                    effective_setting_id,
-                    matching_kp.id if matching_kp else None,
-                    "printer" if printer_kp else "stored",
-                )
-
-            mqtt_client.ams_set_filament_setting(
-                ams_id=body.ams_id,
-                tray_id=body.tray_id,
-                tray_info_idx=effective_tray_info_idx,
-                tray_type=tray_type,
-                tray_sub_brands=tray_sub_brands,
-                tray_color=tray_color,
-                nozzle_temp_min=temp_min,
-                nozzle_temp_max=temp_max,
-                setting_id=effective_setting_id,
-            )
-
-            if matching_kp and matching_kp.cali_idx is not None:
-                # Use printer-reported filament_id when available, otherwise
-                # fall back to the realigned tray_info_idx so both commands
-                # reference the same filament context.
-                cali_filament_id = (
-                    printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
-                ) or effective_tray_info_idx
-                mqtt_client.extrusion_cali_sel(
-                    ams_id=body.ams_id,
-                    tray_id=body.tray_id,
-                    cali_idx=matching_kp.cali_idx,
-                    filament_id=cali_filament_id,
-                    nozzle_diameter=nozzle_diameter,
-                )
-                logger.info(
-                    "Spoolman assign: applied K-profile cali_idx=%d "
-                    "(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
-                    matching_kp.cali_idx,
-                    matching_kp.id,
-                    cali_filament_id,
-                    body.spoolman_spool_id,
-                    body.printer_id,
-                    body.ams_id,
-                    body.tray_id,
-                )
-            else:
-                # No stored K-profile: preserve the slot's current live cali_idx
-                from backend.app.api.routes.inventory import _find_tray_in_ams_data
-
-                live_tray = None
-                if state and state.raw_data:
-                    ams_raw = state.raw_data.get("ams", [])
-                    if isinstance(ams_raw, dict):
-                        ams_raw = ams_raw.get("ams", [])
-                    live_tray = _find_tray_in_ams_data(ams_raw, body.ams_id, body.tray_id)
-                live_cali_idx = (live_tray or {}).get("cali_idx")
-                if live_cali_idx is not None and live_cali_idx >= 0:
-                    mqtt_client.extrusion_cali_sel(
-                        ams_id=body.ams_id,
-                        tray_id=body.tray_id,
-                        cali_idx=live_cali_idx,
-                        filament_id=effective_tray_info_idx,
-                        nozzle_diameter=nozzle_diameter,
-                    )
-                    logger.info(
-                        "No stored K-profile for Spoolman spool %d — preserved live cali_idx=%d",
-                        body.spoolman_spool_id,
-                        live_cali_idx,
-                    )
-
-            logger.info(
-                "Auto-configured AMS slot ams=%d tray=%d for Spoolman spool %d on printer %d",
-                body.ams_id,
-                body.tray_id,
-                body.spoolman_spool_id,
-                body.printer_id,
-            )
-    except Exception:
-        logger.exception(
-            "Failed to auto-configure AMS slot for Spoolman spool %d (printer=%d, ams=%d, tray=%d)",
-            body.spoolman_spool_id,
-            body.printer_id,
-            body.ams_id,
-            body.tray_id,
-        )
-
-    return mapped
-
-
-@router.delete("/slot-assignments/{spoolman_spool_id}")
-async def unassign_spoolman_slot(
-    spoolman_spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> dict:
-    """Remove the local slot assignment for a Spoolman spool.
-
-    Spoolman's own ``spool.location`` field is NOT touched — it is user-managed.
-    """
-    client = await _get_client(db)
-
-    try:
-        await db.execute(
-            delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
-        )
-        await db.commit()
-    except Exception as exc:
-        await db.rollback()
-        logger.error("Failed to delete slot assignment: %s", exc)
-        raise HTTPException(status_code=500, detail="Failed to remove slot assignment") from exc
-
-    # Fetch the spool from Spoolman to return in InventorySpool format.
-    # If the spool no longer exists in Spoolman, the local unassignment still succeeded.
-    try:
-        async with _translate_spoolman_errors():
-            spool = await client.get_spool(spoolman_spool_id)
-        return _map_spoolman_spool(spool)
-    except HTTPException as exc:
-        if exc.status_code != 404:
-            raise
-        # Spool no longer exists in Spoolman; unassignment still succeeded.
-        return {"id": spoolman_spool_id}
-
-
-@router.get("/slot-assignments")
-async def get_spoolman_slot_assignment(
-    printer_id: int = Query(..., gt=0),
-    ams_id: int = Query(..., ge=0, le=7),
-    tray_id: int = Query(..., ge=0, le=3),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
-) -> dict | None:
-    """Return the Spoolman spool assigned to a specific printer slot, or null if unassigned."""
-    client = await _get_client(db)
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(status_code=404, detail="Printer not found")
-
-    slot_result = await db.execute(
-        select(SpoolmanSlotAssignment).where(
-            SpoolmanSlotAssignment.printer_id == printer_id,
-            SpoolmanSlotAssignment.ams_id == ams_id,
-            SpoolmanSlotAssignment.tray_id == tray_id,
-        )
-    )
-    slot = slot_result.scalar_one_or_none()
-    if not slot:
-        return None
-
-    try:
-        async with _translate_spoolman_errors():
-            spool = await client.get_spool(slot.spoolman_spool_id)
-        return _map_spoolman_spool(spool)
-    except HTTPException as exc:
-        if exc.status_code != 404:
-            raise
-        # Spool deleted in Spoolman — clean up stale assignment.
-        # Include spoolman_spool_id in WHERE to avoid a TOCTOU race where a
-        # concurrent re-assign changed the slot to a different spool between
-        # the GET and this DELETE.
-        try:
-            await db.execute(
-                delete(SpoolmanSlotAssignment).where(
-                    SpoolmanSlotAssignment.id == slot.id,
-                    SpoolmanSlotAssignment.spoolman_spool_id == slot.spoolman_spool_id,
-                )
-            )
-            await db.commit()
-        except Exception as cleanup_exc:
-            await db.rollback()
-            logger.warning(
-                "Failed to remove stale slot assignment for spool %s: %s",
-                slot.spoolman_spool_id,
-                cleanup_exc,
-            )
-        return None
-
-
-def _k_profile_to_dict(p: SpoolmanKProfile) -> dict:
-    """Manually map SpoolmanKProfile → SpoolKProfileResponse-compatible dict."""
-    return {
-        "id": p.id,
-        "spool_id": p.spoolman_spool_id,
-        "printer_id": p.printer_id,
-        "extruder": p.extruder,
-        "nozzle_diameter": p.nozzle_diameter,
-        "nozzle_type": p.nozzle_type,
-        "k_value": p.k_value,
-        "name": p.name,
-        "cali_idx": p.cali_idx,
-        "setting_id": p.setting_id,
-        "created_at": p.created_at,
-    }
-
-
-def _normalize_filament(raw: dict) -> NormalizedFilament | None:
-    """Normalise a raw Spoolman filament dict for the frontend catalog picker.
-
-    Returns None for entries with missing/zero IDs — those are malformed and
-    must be filtered out before returning to the client.
-    weight=0 is collapsed to None — 0g is not a valid filament weight.
-    """
-    filament_id = _safe_int(raw.get("id"), 0)
-    if filament_id <= 0:
-        logger.warning("Skipping Spoolman filament with missing or invalid id: %r", raw.get("name"))
-        return None
-    vendor = raw.get("vendor") or {}
-    vendor_ref: NormalizedVendorRef | None = None
-    if vendor:
-        vendor_id = _safe_int(vendor.get("id"), 0)
-        if vendor_id <= 0:
-            logger.warning("Spoolman filament %d has vendor without valid id — vendor omitted", filament_id)
-        else:
-            vendor_ref = {"id": vendor_id, "name": str(vendor.get("name") or "").strip() or "Unknown"}
-    return NormalizedFilament(
-        id=filament_id,
-        name=str(raw.get("name") or ""),
-        material=raw.get("material") or None,
-        color_hex=raw.get("color_hex") or None,
-        color_name=raw.get("color_name") or None,
-        weight=_safe_int(raw.get("weight"), 0) or None,  # 0g is not a valid weight
-        spool_weight=_safe_optional_float(raw.get("spool_weight")),
-        vendor=vendor_ref,
-    )
-
-
-@router.get("/filaments")
-async def list_spoolman_filaments(
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
-) -> list[NormalizedFilament]:
-    """Return all filaments from Spoolman, normalised for the frontend catalog picker."""
-    client = await _get_client(db)
-    async with _translate_spoolman_errors():
-        raw_filaments = await client.get_filaments()
-    if not isinstance(raw_filaments, list):
-        logger.warning("Spoolman get_filaments() returned non-list type: %s", type(raw_filaments).__name__)
-        return []
-    return [f for raw in raw_filaments if (f := _normalize_filament(raw)) is not None]
-
-
-@router.patch("/filaments/{filament_id}")
-async def patch_spoolman_filament(
-    *,
-    filament_id: int = Path(..., gt=0),
-    body: SpoolmanFilamentPatch = Body(...),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> NormalizedFilament:
-    """Update a Spoolman filament's name and/or spool_weight.
-
-    When spool_weight changes, Option A (keep_existing_spools=True) stamps the old
-    weight onto spools currently inheriting it (spool.spool_weight is None) so their
-    tare calculations are unaffected by the filament change.
-    Option B (keep_existing_spools=False, the default): when spool_weight is a
-    concrete value, stamps it onto every affected spool explicitly; when spool_weight
-    is null, clears per-spool overrides so spools fall back to the filament value.
-    """
-    client = await _get_client(db)
-
-    async with _translate_spoolman_errors():
-        current = await client.get_filament(filament_id)
-
-    patch_data = {k: v for k, v in body.model_dump(exclude_unset=True).items() if k != "keep_existing_spools"}
-    if not patch_data:
-        normalized = _normalize_filament(current)
-        if normalized is None:
-            raise HTTPException(status_code=404, detail="Filament not found")
-        return normalized
-
-    async with _translate_spoolman_errors():
-        updated = await client.patch_filament(filament_id, patch_data)
-
-    if "spool_weight" in body.model_fields_set:
-        async with _translate_spoolman_errors():
-            all_spools = await client.get_all_spools()
-        affected_spools = [s for s in all_spools if (s.get("filament") or {}).get("id") == filament_id]
-
-        if affected_spools:
-            if body.keep_existing_spools:
-                old_weight = _safe_optional_float(current.get("spool_weight"))
-                if old_weight is not None:
-                    spools_to_fix = [s for s in affected_spools if s.get("spool_weight") is None]
-                    if spools_to_fix:
-                        async with _translate_spoolman_errors():
-                            results = await asyncio.gather(
-                                *(
-                                    client.update_spool_full(spool_id=s["id"], spool_weight=old_weight)
-                                    for s in spools_to_fix
-                                ),
-                                return_exceptions=True,
-                            )
-                        _raise_if_partial_failure(spools_to_fix, results, "spool_weight stamp (option A)")
-            else:
-                new_weight = body.spool_weight
-                if new_weight is not None:
-                    # Stamp the new weight onto every spool of this filament type so
-                    # each spool carries the value explicitly rather than inheriting.
-                    async with _translate_spoolman_errors():
-                        results = await asyncio.gather(
-                            *(
-                                client.update_spool_full(spool_id=s["id"], spool_weight=new_weight)
-                                for s in affected_spools
-                            ),
-                            return_exceptions=True,
-                        )
-                    _raise_if_partial_failure(affected_spools, results, "spool_weight stamp (option B)")
-                else:
-                    # Filament weight is being cleared — remove any per-spool override
-                    # so spools fall back to whatever the filament now provides.
-                    spools_to_clear = [s for s in affected_spools if s.get("spool_weight") is not None]
-                    if spools_to_clear:
-                        async with _translate_spoolman_errors():
-                            results = await asyncio.gather(
-                                *(
-                                    client.update_spool_full(spool_id=s["id"], clear_spool_weight=True)
-                                    for s in spools_to_clear
-                                ),
-                                return_exceptions=True,
-                            )
-                        _raise_if_partial_failure(spools_to_clear, results, "spool_weight clear (option B null)")
-
-    normalized = _normalize_filament(updated)
-    if normalized is None:
-        raise HTTPException(status_code=502, detail="Spoolman returned malformed filament data")
-    return normalized
-
-
-@router.get("/spools/{spool_id}/k-profiles")
-async def get_spoolman_k_profiles(
-    spool_id: int = Path(..., gt=0),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
-) -> list[dict]:
-    """Return all local K-value calibration profiles for a Spoolman spool."""
-    await _get_client(db)
-    result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
-    profiles = result.scalars().all()
-    return [_k_profile_to_dict(p) for p in profiles]
-
-
-@router.put("/spools/{spool_id}/k-profiles")
-async def save_spoolman_k_profiles(
-    spool_id: int = Path(..., gt=0),
-    profiles: list[SpoolKProfileBase] = Body(...),
-    db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
-) -> list[dict]:
-    """Replace all K-value calibration profiles for a Spoolman spool."""
-    client = await _get_client(db)
-    async with _translate_spoolman_errors():
-        await client.get_spool(spool_id)
-
-    saved: list[SpoolmanKProfile] = []
-    try:
-        await db.execute(delete(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
-        for profile in profiles:
-            obj = SpoolmanKProfile(
-                spoolman_spool_id=spool_id,
-                printer_id=profile.printer_id,
-                extruder=profile.extruder,
-                nozzle_diameter=profile.nozzle_diameter,
-                nozzle_type=profile.nozzle_type,
-                k_value=profile.k_value,
-                name=profile.name,
-                cali_idx=profile.cali_idx,
-                setting_id=profile.setting_id,
-            )
-            db.add(obj)
-            saved.append(obj)
-        await db.commit()
-    except IntegrityError as exc:
-        await db.rollback()
-        raise HTTPException(422, "Duplicate or invalid K-profile (check printer_id and nozzle uniqueness)") from exc
-    except Exception as exc:
-        await db.rollback()
-        logger.error("K-profile save for spool %d failed: %s", spool_id, exc)
-        raise HTTPException(500, "Failed to save K-profiles") from exc
-
-    for obj in saved:
-        await db.refresh(obj)
-
-    return [_k_profile_to_dict(p) for p in saved]

+ 2 - 51
backend/app/core/auth.py

@@ -25,42 +25,6 @@ from backend.app.models.user import User
 
 logger = logging.getLogger(__name__)
 
-# SETTINGS_READ is intentionally not denied — the SpoolBuddy kiosk reads settings
-# via API key (e.g. to sync the UI language).
-_APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
-    {
-        Permission.SETTINGS_UPDATE,
-        Permission.SETTINGS_BACKUP,
-        Permission.SETTINGS_RESTORE,
-        Permission.USERS_READ,
-        Permission.USERS_CREATE,
-        Permission.USERS_UPDATE,
-        Permission.USERS_DELETE,
-        Permission.GROUPS_READ,
-        Permission.GROUPS_CREATE,
-        Permission.GROUPS_UPDATE,
-        Permission.GROUPS_DELETE,
-        Permission.API_KEYS_CREATE,
-        Permission.API_KEYS_UPDATE,
-        Permission.API_KEYS_DELETE,
-        Permission.API_KEYS_READ,
-        Permission.GITHUB_BACKUP,
-        Permission.GITHUB_RESTORE,
-        Permission.FIRMWARE_UPDATE,
-    }
-)
-
-
-def _check_apikey_permissions(perm_strings: list[str]) -> None:
-    """Raise 403 if any required permission is admin-only (not accessible via API key)."""
-    denied = _APIKEY_DENIED_PERMISSIONS.intersection(perm_strings)
-    if denied:
-        raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail="API keys cannot be used for administrative operations",
-        )
-
-
 # Password hashing
 # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
 # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
@@ -708,7 +672,8 @@ async def get_api_key(
             detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
         )
 
-    # Pre-filter by key_prefix to avoid O(n) pbkdf2 hashes across all enabled keys.
+    # M-NEW-2: Pre-filter by key_prefix (first 8 chars) to avoid O(n) pbkdf2 over all
+    # enabled keys — same fix as in _validate_api_key (L-1 from previous review).
     key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
     result = await db.execute(
         select(APIKey).where(
@@ -745,16 +710,6 @@ async def get_api_key(
     )
 
 
-async def caller_is_api_key(
-    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
-    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-) -> bool:
-    """Return True when the request is authenticated via API key (X-API-Key or Bearer bb_xxx)."""
-    if x_api_key:
-        return True
-    return credentials is not None and credentials.credentials.startswith("bb_")
-
-
 def check_permission(api_key: APIKey, permission: str) -> None:
     """Check if API key has the required permission.
 
@@ -842,7 +797,6 @@ def require_permission(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
 
             credentials_exception = HTTPException(
@@ -859,7 +813,6 @@ def require_permission(*permissions: str | Permission):
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -932,7 +885,6 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
 
             # Check for Bearer token (could be JWT or API key)
@@ -942,7 +894,6 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        _check_apikey_permissions(perm_strings)
                         return None  # API key valid, allow access
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,

+ 37 - 146
backend/app/core/database.py

@@ -205,8 +205,6 @@ async def init_db():
         spool_k_profile,
         spool_usage_history,
         spoolbuddy_device,
-        spoolman_k_profile,
-        spoolman_slot_assignment,
         user,
         user_email_pref,
         user_otp_code,
@@ -1196,36 +1194,25 @@ async def run_migrations(conn):
         pass  # Already applied
 
     # Create active_print_spoolman table for Spoolman per-filament tracking
-    await _safe_execute(
-        conn,
-        """
-        CREATE TABLE IF NOT EXISTS active_print_spoolman (
-            id INTEGER PRIMARY KEY AUTOINCREMENT,
-            printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
-            filament_usage TEXT NOT NULL,
-            ams_trays TEXT NOT NULL,
-            slot_to_tray TEXT,
-            layer_usage TEXT,
-            filament_properties TEXT,
-            UNIQUE(printer_id, archive_id)
-        )
-        """
-        if is_sqlite()
-        else """
-        CREATE TABLE IF NOT EXISTS active_print_spoolman (
-            id SERIAL PRIMARY KEY,
-            printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
-            filament_usage TEXT NOT NULL,
-            ams_trays TEXT NOT NULL,
-            slot_to_tray TEXT,
-            layer_usage TEXT,
-            filament_properties TEXT,
-            UNIQUE(printer_id, archive_id)
-        )
-        """,
-    )
+    try:
+        async with conn.begin_nested():
+            await conn.execute(
+                text("""
+                CREATE TABLE IF NOT EXISTS active_print_spoolman (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
+                    archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
+                    filament_usage TEXT NOT NULL,
+                    ams_trays TEXT NOT NULL,
+                    slot_to_tray TEXT,
+                    layer_usage TEXT,
+                    filament_properties TEXT,
+                    UNIQUE(printer_id, archive_id)
+                )
+            """)
+            )
+    except (OperationalError, ProgrammingError):
+        pass  # Already applied
 
     # Migration: Add preset_source column to slot_preset_mappings for local preset support
     try:
@@ -1254,34 +1241,24 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER")
 
     # Migration: Create spool_usage_history table for filament consumption tracking
-    await _safe_execute(
-        conn,
-        """
-        CREATE TABLE IF NOT EXISTS spool_usage_history (
-            id INTEGER PRIMARY KEY AUTOINCREMENT,
-            spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
-            printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
-            print_name VARCHAR(500),
-            weight_used REAL NOT NULL DEFAULT 0,
-            percent_used INTEGER NOT NULL DEFAULT 0,
-            status VARCHAR(20) NOT NULL DEFAULT 'completed',
-            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-        )
-        """
-        if is_sqlite()
-        else """
-        CREATE TABLE IF NOT EXISTS spool_usage_history (
-            id SERIAL PRIMARY KEY,
-            spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
-            printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
-            print_name VARCHAR(500),
-            weight_used REAL NOT NULL DEFAULT 0,
-            percent_used INTEGER NOT NULL DEFAULT 0,
-            status VARCHAR(20) NOT NULL DEFAULT 'completed',
-            created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
-        )
-        """,
-    )
+    try:
+        async with conn.begin_nested():
+            await conn.execute(
+                text("""
+                CREATE TABLE IF NOT EXISTS spool_usage_history (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
+                    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+                    print_name VARCHAR(500),
+                    weight_used REAL NOT NULL DEFAULT 0,
+                    percent_used INTEGER NOT NULL DEFAULT 0,
+                    status VARCHAR(20) NOT NULL DEFAULT 'completed',
+                    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+                )
+            """)
+            )
+    except (OperationalError, ProgrammingError):
+        pass  # Already applied
 
     # Migration: Add open_in_new_tab column to external_links
     await _safe_execute(conn, "ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0")
@@ -1313,13 +1290,6 @@ async def run_migrations(conn):
     # falls back to the global low_stock_threshold setting.
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
-    # Migration: Add user-editable storage location to spool table
-    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN storage_location VARCHAR(255)")
-    # Migration: Widen tag_uid column from VARCHAR(16) to VARCHAR(32) to accommodate 7-byte NFC
-    # UIDs (14 hex chars) in addition to 8-byte Bambu Lab UIDs (16 hex chars).
-    # ALTER COLUMN ... TYPE is PostgreSQL-only syntax; SQLite ignores VARCHAR sizes so no-op there.
-    if not is_sqlite():
-        await _safe_execute(conn, "ALTER TABLE spool ALTER COLUMN tag_uid TYPE VARCHAR(32)")
 
     # Migration: enhanced filament colour handling (#1154). `extra_colors` is
     # a comma-separated list of 6- or 8-char hex tokens (no `#`) for multi-
@@ -1427,9 +1397,6 @@ async def run_migrations(conn):
     # Migration: Add system_stats JSON blob column to spoolbuddy_devices
     await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT")
 
-    # Migration: Add SSH host key for TOFU verification (H1 security fix)
-    await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN ssh_host_key VARCHAR(500)")
-
     # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     # PostgreSQL gets the correct schema from create_all(), so skip this
@@ -1891,82 +1858,6 @@ async def run_migrations(conn):
             )
         )
 
-    # Migration: Create spoolman_slot_assignments table for local AMS-slot→Spoolman-spool mapping.
-    # Replaces the pattern of writing spool.location in Spoolman (which polluted the
-    # user-editable storage_location field in the UI).
-    await _safe_execute(
-        conn,
-        """
-        CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
-            id INTEGER PRIMARY KEY AUTOINCREMENT,
-            printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR ams_id = 255),
-            tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
-            spoolman_spool_id INTEGER NOT NULL,
-            assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
-        )
-        """
-        if is_sqlite()
-        else """
-        CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
-            id SERIAL PRIMARY KEY,
-            printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR ams_id = 255),
-            tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
-            spoolman_spool_id INTEGER NOT NULL,
-            assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
-        )
-        """,
-    )
-    await _safe_execute(
-        conn,
-        "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool ON spoolman_slot_assignments (spoolman_spool_id)",
-    )
-
-    # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
-    await _safe_execute(
-        conn,
-        """
-        CREATE TABLE IF NOT EXISTS spoolman_k_profile (
-            id INTEGER PRIMARY KEY AUTOINCREMENT,
-            spoolman_spool_id INTEGER NOT NULL,
-            printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            extruder INTEGER NOT NULL DEFAULT 0 CHECK (extruder >= 0 AND extruder <= 1),
-            nozzle_diameter VARCHAR(10) NOT NULL DEFAULT '0.4',
-            nozzle_type VARCHAR(50),
-            k_value REAL NOT NULL,
-            name VARCHAR(100),
-            cali_idx INTEGER,
-            setting_id VARCHAR(50),
-            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            CONSTRAINT uq_spoolman_k_profile UNIQUE(spoolman_spool_id, printer_id, extruder, nozzle_diameter)
-        )
-        """
-        if is_sqlite()
-        else """
-        CREATE TABLE IF NOT EXISTS spoolman_k_profile (
-            id SERIAL PRIMARY KEY,
-            spoolman_spool_id INTEGER NOT NULL,
-            printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            extruder INTEGER NOT NULL DEFAULT 0 CHECK (extruder >= 0 AND extruder <= 1),
-            nozzle_diameter VARCHAR(10) NOT NULL DEFAULT '0.4',
-            nozzle_type VARCHAR(50),
-            k_value DOUBLE PRECISION NOT NULL,
-            name VARCHAR(100),
-            cali_idx INTEGER,
-            setting_id VARCHAR(50),
-            created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            CONSTRAINT uq_spoolman_k_profile UNIQUE(spoolman_spool_id, printer_id, extruder, nozzle_diameter)
-        )
-        """,
-    )
-    await _safe_execute(
-        conn,
-        "CREATE INDEX IF NOT EXISTS ix_spoolman_k_profile_spool ON spoolman_k_profile (spoolman_spool_id)",
-    )
-
     # Migration: Add provider column to github_backup_config for multi-provider support
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN provider VARCHAR(30) DEFAULT 'github'")
 

+ 22 - 169
backend/app/main.py

@@ -55,7 +55,6 @@ from backend.app.api.routes import (
     smart_plugs,
     spoolbuddy,
     spoolman,
-    spoolman_inventory,
     support,
     system,
     updates,
@@ -1175,93 +1174,6 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                         )
                                         existing_assignment.spool.weight_used = new_used
                                         await db.commit()
-
-                            # Re-apply stored K-profile when the live tray's
-                            # cali_idx drifted from the spool's stored profile.
-                            # This catches "reset slot → re-read" and any other
-                            # path where the firmware loses the user's K-profile
-                            # selection while the SpoolAssignment row persists.
-                            # Per the maintainer's rule: any time a spool tag is
-                            # identified and matches inventory, the slot must be
-                            # configured with the spool's stored settings. Without
-                            # this block the existing-assignment branch only ran
-                            # weight-sync and let the firmware-default cali_idx win.
-                            try:
-                                spool = existing_assignment.spool
-                                if (
-                                    spool is not None
-                                    and is_bambu_tag(tag_uid, tray_uuid, tray_info_idx)
-                                    and spool.k_profiles
-                                ):
-                                    state = printer_manager.get_status(printer_id)
-                                    nozzle_diameter = "0.4"
-                                    if state and state.nozzles:
-                                        nd = state.nozzles[0].nozzle_diameter
-                                        if nd:
-                                            nozzle_diameter = nd
-                                    slot_extruder: int | None = None
-                                    if state and state.ams_extruder_map:
-                                        if ams_id == 255:
-                                            slot_extruder = 1 - tray_id
-                                        else:
-                                            slot_extruder = state.ams_extruder_map.get(str(ams_id))
-                                    # Prefer exact extruder match, fall back to
-                                    # extruder-agnostic kp for the same printer +
-                                    # nozzle. Avoids hard-skipping when the AMS is
-                                    # mapped differently than at calibration time.
-                                    matching_kp = None
-                                    fallback_kp = None
-                                    for kp in spool.k_profiles:
-                                        if (
-                                            kp.printer_id != printer_id
-                                            or kp.nozzle_diameter != nozzle_diameter
-                                            or kp.cali_idx is None
-                                        ):
-                                            continue
-                                        if (
-                                            slot_extruder is not None
-                                            and kp.extruder is not None
-                                            and kp.extruder == slot_extruder
-                                        ):
-                                            matching_kp = kp
-                                            break
-                                        if fallback_kp is None:
-                                            fallback_kp = kp
-                                    chosen_kp = matching_kp or fallback_kp
-                                    if chosen_kp is not None:
-                                        live_cali_idx = tray.get("cali_idx")
-                                        # Only fire MQTT when the printer's live
-                                        # cali_idx differs from the stored value.
-                                        # Avoids spamming the broker on every
-                                        # MQTT push during steady-state operation.
-                                        if live_cali_idx != chosen_kp.cali_idx:
-                                            client = printer_manager.get_client(printer_id)
-                                            if client:
-                                                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
-                                                client.extrusion_cali_sel(
-                                                    ams_id=ams_id,
-                                                    tray_id=tray_id,
-                                                    cali_idx=chosen_kp.cali_idx,
-                                                    filament_id=cali_filament_id,
-                                                    nozzle_diameter=nozzle_diameter,
-                                                )
-                                                logger.info(
-                                                    "Re-applied K-profile cali_idx=%d for spool %d "
-                                                    "on printer %d AMS%d-T%d (live=%s drift detected)",
-                                                    chosen_kp.cali_idx,
-                                                    spool.id,
-                                                    printer_id,
-                                                    ams_id,
-                                                    tray_id,
-                                                    live_cali_idx,
-                                                )
-                            except Exception:
-                                logger.exception(
-                                    "K-profile re-apply failed for printer %d AMS%d-T%d",
-                                    printer_id,
-                                    ams_id,
-                                    tray_id,
-                                )
                             continue
 
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
@@ -1354,11 +1266,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
             # Get or create Spoolman client
             client = await get_spoolman_client()
             if not client:
-                try:
-                    client = await init_spoolman_client(spoolman_url)
-                except ValueError as exc:
-                    logger.warning("Spoolman URL %r rejected by SSRF guard: %s", spoolman_url, exc)
-                    return
+                client = await init_spoolman_client(spoolman_url)
 
             # Check if Spoolman is reachable
             if not await client.health_check():
@@ -1388,7 +1296,6 @@ async def on_ams_change(printer_id: int, ams_data: list):
             from sqlalchemy.orm import selectinload
 
             from backend.app.models.spool_assignment import SpoolAssignment
-            from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 
             inventory_weights: dict[tuple[int, int], float] = {}
             try:
@@ -1403,47 +1310,29 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
                         inventory_weights[(assignment.ams_id, assignment.tray_id)] = remaining
             except Exception as e:
-                logger.warning("Could not load inventory weights for printer %s: %s", printer_id, e)
+                logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
 
-            # Load existing Spoolman slot assignments for the no-RFID fallback path
-            spoolman_slot_map: dict[tuple[int, int], int] = {}
-            try:
-                slot_result = await db.execute(
-                    select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
-                )
-                for slot in slot_result.scalars().all():
-                    spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
-            except Exception as e:
-                logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
-
-            # Sync each AMS tray and collect slot changes for DB persistence
+            # Sync each AMS tray, tracking UUIDs and spool IDs for cleanup
             synced = 0
-            slot_changes: list[tuple[int, int, int]] = []  # (ams_id, tray_id, spoolman_spool_id) to upsert
-            empty_slots: list[tuple[int, int]] = []  # (ams_id, tray_id) whose tray is now empty
+            current_tray_uuids: set[str] = set()
+            synced_spool_ids: set[int] = set()
             for ams_unit in ams_data:
-                if not isinstance(ams_unit, dict):
-                    continue
                 ams_id = int(ams_unit.get("id", 0))
                 trays = ams_unit.get("tray", [])
 
                 for tray_data in trays:
-                    if not isinstance(tray_data, dict):
-                        continue
-                    tray_id_raw = int(tray_data.get("id", 0))
                     tray = client.parse_ams_tray(ams_id, tray_data)
                     if not tray:
-                        # Empty tray slot — record for local assignment cleanup
-                        empty_slots.append((ams_id, tray_id_raw))
-                        continue
+                        continue  # Empty tray
 
+                    # Track this spool's UUID as currently present in the AMS
                     spool_tag = (
                         tray.tray_uuid
                         if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
                         else tray.tag_uid
                     )
-
-                    # Provide the hint only when no RFID is available
-                    hint = spoolman_slot_map.get((ams_id, tray.tray_id)) if not spool_tag else None
+                    if spool_tag:
+                        current_tray_uuids.add(spool_tag.upper())
 
                     try:
                         inv_remaining = inventory_weights.get((ams_id, tray.tray_id))
@@ -1453,14 +1342,14 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             disable_weight_sync=disable_weight_sync,
                             cached_spools=cached_spools,
                             inventory_remaining=inv_remaining,
-                            spoolman_spool_id_hint=hint,
                         )
                         if result:
                             synced += 1
                             if result.get("id"):
-                                slot_changes.append((ams_id, tray.tray_id, result["id"]))
+                                synced_spool_ids.add(result["id"])
                                 # If a new spool was created, add it to the cache
                                 # so subsequent trays can find it if they reference the same tag
+                                # Check if this spool already exists in cache
                                 spool_exists = any(s.get("id") == result["id"] for s in cached_spools)
                                 if not spool_exists:
                                     cached_spools.append(result)
@@ -1475,40 +1364,18 @@ async def on_ams_change(printer_id: int, ams_data: list):
             if synced > 0:
                 logger.info("Auto-synced %s AMS trays to Spoolman for printer %s", synced, printer_id)
 
-            # Persist slot assignment changes to the local table
-            if slot_changes or empty_slots:
-                try:
-                    for ams_id, tray_id, spool_id in slot_changes:
-                        await db.execute(
-                            text(
-                                "INSERT INTO spoolman_slot_assignments"
-                                " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                                " ON CONFLICT(printer_id, ams_id, tray_id)"
-                                " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-                            ),
-                            {
-                                "printer_id": printer_id,
-                                "ams_id": ams_id,
-                                "tray_id": tray_id,
-                                "spool_id": spool_id,
-                            },
-                        )
-                    for ams_id, tray_id in empty_slots:
-                        await db.execute(
-                            delete(SpoolmanSlotAssignment).where(
-                                SpoolmanSlotAssignment.printer_id == printer_id,
-                                SpoolmanSlotAssignment.ams_id == ams_id,
-                                SpoolmanSlotAssignment.tray_id == tray_id,
-                            )
-                        )
-                    await db.commit()
-                except Exception as e:
-                    await db.rollback()
-                    logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
+            # Clear location for spools no longer in this printer's AMS
+            try:
+                cleared = await client.clear_location_for_removed_spools(
+                    printer_name, current_tray_uuids, cached_spools=cached_spools, synced_spool_ids=synced_spool_ids
+                )
+                if cleared > 0:
+                    logger.info("Auto-cleared location for %s spools removed from printer %s", cleared, printer_id)
+            except Exception as e:
+                logger.error("Error clearing locations for removed spools on printer %s: %s", printer_id, e)
 
     except Exception as e:
-        logging.getLogger(__name__).error("Spoolman AMS sync failed for printer %s: %s", printer_id, e)
+        logging.getLogger(__name__).warning(f"Spoolman AMS sync failed: {e}")
 
 
 async def _capture_snapshot_for_notification(printer_id: int, printer, logger) -> bytes | None:
@@ -4672,20 +4539,7 @@ async def lifespan(app: FastAPI):
                 if await client.health_check():
                     logging.info("Auto-connected to Spoolman at %s", spoolman_url)
                     # Ensure the 'tag' extra field exists for RFID/UUID storage
-                    field_ok = await client.ensure_tag_extra_field()
-                    if not field_ok:
-                        logging.error("Spoolman tag extra field registration failed — NFC tag links may not persist")
-                    # Register the BambuStudio slicer-preset fields used by the
-                    # spool-edit / assign flow. Spoolman rejects PATCHes with
-                    # unknown extra keys, so these must exist before any update
-                    # that touches them.
-                    for field_name in ("bambu_slicer_filament", "bambu_slicer_filament_name"):
-                        if not await client.ensure_extra_field(field_name):
-                            logging.warning(
-                                "Spoolman extra field %r registration failed — "
-                                "spool slicer-preset edits will return 502",
-                                field_name,
-                            )
+                    await client.ensure_tag_extra_field()
                 else:
                     logging.warning("Spoolman at %s is not reachable", spoolman_url)
             except Exception as e:
@@ -5205,7 +5059,6 @@ app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
 app.include_router(user_notifications.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.router, prefix=app_settings.api_prefix)
-app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)

+ 1 - 3
backend/app/models/spool.py

@@ -54,11 +54,9 @@ class Spool(Base):
     # Cost tracking
     cost_per_kg: Mapped[float | None] = mapped_column(Float)  # Cost per kilogram
 
-    storage_location: Mapped[str | None] = mapped_column(String(255))  # User-editable storage location
-
     last_used: Mapped[datetime | None] = mapped_column(DateTime)  # Last time this spool was used in a print
     encode_time: Mapped[datetime | None] = mapped_column(DateTime)  # When spool was encoded/written to tag
-    tag_uid: Mapped[str | None] = mapped_column(String(32))  # RFID tag UID (up to 32 hex chars)
+    tag_uid: Mapped[str | None] = mapped_column(String(16))  # RFID tag UID (16 hex chars)
     tray_uuid: Mapped[str | None] = mapped_column(String(32))  # Bambu Lab spool UUID (32 hex chars)
     data_origin: Mapped[str | None] = mapped_column(String(20))  # How data was populated: manual, rfid_auto, nfc_link
     tag_type: Mapped[str | None] = mapped_column(String(20))  # Tag vendor: bambulab, generic, etc.

+ 0 - 1
backend/app/models/spoolbuddy_device.py

@@ -37,6 +37,5 @@ class SpoolBuddyDevice(Base):
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     system_stats: Mapped[str | None] = mapped_column(Text, nullable=True)
-    ssh_host_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 0 - 34
backend/app/models/spoolman_k_profile.py

@@ -1,34 +0,0 @@
-from datetime import datetime
-
-from sqlalchemy import CheckConstraint, DateTime, Float, ForeignKey, Integer, String, UniqueConstraint, func
-from sqlalchemy.orm import Mapped, mapped_column, relationship
-
-from backend.app.core.database import Base
-
-
-class SpoolmanKProfile(Base):
-    """K-value calibration profile for a Spoolman spool on a specific printer/nozzle combo."""
-
-    __tablename__ = "spoolman_k_profile"
-
-    __table_args__ = (
-        UniqueConstraint("spoolman_spool_id", "printer_id", "extruder", "nozzle_diameter"),
-        CheckConstraint("extruder >= 0 AND extruder <= 1", name="ck_extruder_range"),
-    )
-
-    id: Mapped[int] = mapped_column(primary_key=True)
-    spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False)
-    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
-    extruder: Mapped[int] = mapped_column(Integer, default=0)
-    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="0.4")
-    nozzle_type: Mapped[str | None] = mapped_column(String(50))
-    k_value: Mapped[float] = mapped_column(Float)
-    name: Mapped[str | None] = mapped_column(String(100))
-    cali_idx: Mapped[int | None] = mapped_column(Integer)
-    setting_id: Mapped[str | None] = mapped_column(String(50))
-    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
-
-    printer: Mapped["Printer"] = relationship()
-
-
-from backend.app.models.printer import Printer  # noqa: E402, F401

+ 0 - 35
backend/app/models/spoolman_slot_assignment.py

@@ -1,35 +0,0 @@
-from datetime import datetime
-
-from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Integer, UniqueConstraint, func
-from sqlalchemy.orm import Mapped, mapped_column, relationship
-
-from backend.app.core.database import Base
-
-
-class SpoolmanSlotAssignment(Base):
-    """Assignment of a Spoolman spool to a specific AMS slot on a printer.
-
-    Tracks which Spoolman spool ID occupies a given (printer, ams, tray) slot.
-    This is the source of truth for Spoolman slot assignments — Spoolman's own
-    ``spool.location`` field is NOT managed by Bambuddy and is left for the user.
-    """
-
-    __tablename__ = "spoolman_slot_assignments"
-
-    id: Mapped[int] = mapped_column(primary_key=True)
-    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
-    ams_id: Mapped[int] = mapped_column(Integer)
-    tray_id: Mapped[int] = mapped_column(Integer)
-    spoolman_spool_id: Mapped[int] = mapped_column(Integer)
-    assigned_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
-
-    printer: Mapped["Printer"] = relationship()
-
-    __table_args__ = (
-        UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_assignment"),
-        CheckConstraint("(ams_id >= 0 AND ams_id <= 7) OR ams_id = 255", name="ck_ams_id_range"),
-        CheckConstraint("tray_id >= 0 AND tray_id <= 3", name="ck_tray_id_range"),
-    )
-
-
-from backend.app.models.printer import Printer  # noqa: E402, F401

+ 32 - 48
backend/app/schemas/spoolbuddy.py

@@ -1,8 +1,6 @@
-import json
 from datetime import datetime
-from typing import Literal
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field
 
 # --- Device schemas ---
 
@@ -11,14 +9,14 @@ class DeviceRegisterRequest(BaseModel):
     device_id: str = Field(..., min_length=1, max_length=50)
     hostname: str = Field(..., min_length=1, max_length=100)
     ip_address: str = Field(..., min_length=1, max_length=45)
-    firmware_version: str | None = Field(None, max_length=20)
+    firmware_version: str | None = None
     has_nfc: bool = True
     has_scale: bool = True
     tare_offset: int = 0
     calibration_factor: float = 1.0
-    nfc_reader_type: str | None = Field(None, max_length=20)
-    nfc_connection: str | None = Field(None, max_length=20)
-    backend_url: str | None = Field(None, max_length=255)
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
+    backend_url: str | None = None
     has_backlight: bool = False
 
 
@@ -60,20 +58,13 @@ class HeartbeatRequest(BaseModel):
     nfc_ok: bool = False
     scale_ok: bool = False
     uptime_s: int = 0
-    firmware_version: str | None = Field(None, max_length=20)
-    ip_address: str | None = Field(None, max_length=45)
-    nfc_reader_type: str | None = Field(None, max_length=20)
-    nfc_connection: str | None = Field(None, max_length=20)
-    backend_url: str | None = Field(None, max_length=255)
+    firmware_version: str | None = None
+    ip_address: str | None = None
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
+    backend_url: str | None = None
     system_stats: dict | None = None
 
-    @field_validator("system_stats")
-    @classmethod
-    def _limit_system_stats_size(cls, v: dict | None) -> dict | None:
-        if v is not None and len(json.dumps(v)) > 4096:
-            raise ValueError("system_stats must not exceed 4096 bytes when JSON-encoded")
-        return v
-
 
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
@@ -90,32 +81,32 @@ class HeartbeatResponse(BaseModel):
 
 
 class TagScannedRequest(BaseModel):
-    device_id: str = Field(..., max_length=50)
-    tag_uid: str = Field(..., max_length=32)
-    tray_uuid: str | None = Field(None, max_length=32, pattern=r"^[0-9A-Fa-f]*$")
+    device_id: str
+    tag_uid: str
+    tray_uuid: str | None = None
     sak: int | None = None
-    tag_type: str | None = Field(None, max_length=50)
+    tag_type: str | None = None
     raw_blocks: dict | None = None
 
 
 class TagRemovedRequest(BaseModel):
-    device_id: str = Field(..., max_length=50)
-    tag_uid: str = Field(..., max_length=32)
+    device_id: str
+    tag_uid: str
 
 
 # --- Scale schemas ---
 
 
 class ScaleReadingRequest(BaseModel):
-    device_id: str = Field(..., max_length=50)
-    weight_grams: float = Field(..., allow_inf_nan=False)
+    device_id: str
+    weight_grams: float
     stable: bool = False
     raw_adc: int | None = None
 
 
 class UpdateSpoolWeightRequest(BaseModel):
-    spool_id: int = Field(..., gt=0)
-    weight_grams: float = Field(..., allow_inf_nan=False, ge=0.0, le=100_000.0)
+    spool_id: int
+    weight_grams: float
 
 
 # --- Calibration schemas ---
@@ -140,16 +131,16 @@ class CalibrationResponse(BaseModel):
 
 
 class WriteTagRequest(BaseModel):
-    device_id: str = Field(..., max_length=50)
-    spool_id: int = Field(..., gt=0)
+    device_id: str
+    spool_id: int
 
 
 class WriteTagResultRequest(BaseModel):
-    device_id: str = Field(..., max_length=50)
-    spool_id: int = Field(..., gt=0)
-    tag_uid: str = Field(..., min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
+    device_id: str
+    spool_id: int
+    tag_uid: str
     success: bool
-    message: str | None = Field(None, max_length=500)
+    message: str | None = None
 
 
 class DisplaySettingsRequest(BaseModel):
@@ -163,27 +154,20 @@ class SystemConfigRequest(BaseModel):
 
 
 class SystemCommandRequest(BaseModel):
-    command: str = Field(
-        ..., max_length=50, description="System command: reboot, shutdown, restart_daemon, restart_browser"
-    )
+    command: str = Field(..., description="System command: reboot, shutdown, restart_daemon, restart_browser")
 
 
 class SystemCommandResultRequest(BaseModel):
-    command: str = Field(..., max_length=50)
+    command: str
     success: bool
-    message: str | None = Field(None, max_length=500)
-
-
-class UpdateStatusRequest(BaseModel):
-    status: Literal["updating", "complete", "error"]
-    message: str | None = Field(None, max_length=255)
+    message: str | None = None
 
 
 # --- Diagnostics schemas ---
 
 
 class DiagnosticResultRequest(BaseModel):
-    diagnostic: str = Field(..., max_length=50, description="Diagnostic type: 'nfc', 'scale', or 'read_tag'")
+    diagnostic: str  # 'nfc', 'scale', or 'read_tag'
     success: bool
-    output: str = Field(..., max_length=10_000)
-    exit_code: int = Field(..., ge=-255, le=255)
+    output: str
+    exit_code: int

+ 0 - 30
backend/app/schemas/spoolman.py

@@ -1,30 +0,0 @@
-from pydantic import BaseModel, Field, model_validator
-
-
-class SpoolmanFilamentPatch(BaseModel):
-    name: str | None = Field(None, min_length=1, max_length=200)
-    spool_weight: float | None = Field(None, ge=0.0, le=10_000.0)
-    keep_existing_spools: bool = False
-
-    @model_validator(mode="after")
-    def keep_existing_requires_weight(self) -> "SpoolmanFilamentPatch":
-        if self.keep_existing_spools and self.spool_weight is None:
-            raise ValueError("keep_existing_spools=True requires spool_weight to be provided")
-        return self
-
-
-class SpoolmanSlotAssignmentEnriched(BaseModel):
-    """Slot assignment row enriched with printer name and AMS label.
-
-    ``printer_name`` is null only in the cascade-deleted edge case where the
-    Printer relation has been removed. ``ams_label`` is null when no
-    ``ams_labels`` row matches the slot's MQTT serial (or the synthetic
-    ``f"p{printer_id}a{ams_id}"`` fallback key).
-    """
-
-    printer_id: int
-    printer_name: str | None
-    ams_id: int
-    tray_id: int
-    spoolman_spool_id: int
-    ams_label: str | None

+ 23 - 57
backend/app/services/opentag3d.py

@@ -12,55 +12,45 @@ NDEF structure:
   [Terminator: FE]               - 1 byte
 """
 
-import logging
 import struct
 
-from backend.app.api.routes._spoolman_helpers import MappedSpoolFields
 from backend.app.models.spool import Spool
 
-logger = logging.getLogger(__name__)
-
 OPENTAG3D_MIME_TYPE = b"application/opentag3d"
 PAYLOAD_SIZE = 102
 TAG_VERSION = 1000  # v1.000
 
 
-def _build_payload_from_dict(data: dict) -> bytes:
-    """Build 102-byte OpenTag3D core payload from a plain field dict.
-
-    Accepted keys: material, subtype, brand, color_name, rgba,
-    label_weight, nozzle_temp_min.  All are optional and default to
-    safe zero/empty values when missing.
-    """
+def _build_payload(spool: Spool) -> bytes:
+    """Build 102-byte OpenTag3D core payload from spool fields."""
     buf = bytearray(PAYLOAD_SIZE)
 
     # 0x00: Tag Version (2 bytes, big-endian)
     struct.pack_into(">H", buf, 0x00, TAG_VERSION)
 
     # 0x02: Base Material (5 bytes, UTF-8, space-padded)
-    material = (data.get("material") or "")[:5].ljust(5)
+    material = (spool.material or "")[:5].ljust(5)
     buf[0x02:0x07] = material.encode("utf-8")[:5]
 
     # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)
-    modifiers = (data.get("subtype") or "")[:5].ljust(5)
+    modifiers = (spool.subtype or "")[:5].ljust(5)
     buf[0x07:0x0C] = modifiers.encode("utf-8")[:5]
 
     # 0x0C: Reserved (15 bytes, zero-fill) — already zero
 
     # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)
-    brand = (data.get("brand") or "")[:16].ljust(16)
+    brand = (spool.brand or "")[:16].ljust(16)
     buf[0x1B:0x2B] = brand.encode("utf-8")[:16]
 
     # 0x2B: Color Name (32 bytes, UTF-8, space-padded)
-    color_name = (data.get("color_name") or "")[:32].ljust(32)
+    color_name = (spool.color_name or "")[:32].ljust(32)
     buf[0x2B:0x4B] = color_name.encode("utf-8")[:32]
 
     # 0x4B: Color 1 RGBA (4 bytes)
-    rgba_hex = data.get("rgba") or "00000000"
+    rgba_hex = spool.rgba or "00000000"
     try:
         rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
     except ValueError:
-        logger.warning("OpenTag3D encoder: invalid rgba value %r — encoding as transparent black", rgba_hex)
         rgba_bytes = b"\x00\x00\x00\x00"
     buf[0x4B:0x4F] = rgba_bytes[:4]
 
@@ -69,12 +59,11 @@ def _build_payload_from_dict(data: dict) -> bytes:
     # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
     struct.pack_into(">H", buf, 0x5C, 1750)
 
-    # 0x5E: Target Weight (2 bytes, big-endian) — clamped to uint16 (0–65535)
-    label_weight = max(0, min(int(data.get("label_weight") or 0), 65535))
-    struct.pack_into(">H", buf, 0x5E, label_weight)
+    # 0x5E: Target Weight (2 bytes, big-endian)
+    struct.pack_into(">H", buf, 0x5E, spool.label_weight or 0)
 
-    # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5, clamped to 0–255
-    buf[0x60] = max(0, min(int((data.get("nozzle_temp_min") or 0) // 5), 255))
+    # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5
+    buf[0x60] = (spool.nozzle_temp_min or 0) // 5
 
     # 0x61: Bed Temp (1 byte) — not tracked
     # 0x62: Density (2 bytes) — not tracked
@@ -84,26 +73,17 @@ def _build_payload_from_dict(data: dict) -> bytes:
     return bytes(buf)
 
 
-def _build_payload(spool: Spool) -> bytes:
-    """Build 102-byte OpenTag3D core payload from a Spool ORM object."""
-    return _build_payload_from_dict(
-        {
-            "material": spool.material,
-            "subtype": spool.subtype,
-            "brand": spool.brand,
-            "color_name": spool.color_name,
-            "rgba": spool.rgba,
-            "label_weight": spool.label_weight,
-            "nozzle_temp_min": spool.nozzle_temp_min,
-        }
-    )
-
-
-def _encode_ndef(payload: bytes) -> bytes:
-    """Wrap a 102-byte payload in CC + TLV + NDEF record + terminator."""
+def encode_opentag3d(spool: Spool) -> bytes:
+    """Encode spool data as OpenTag3D NDEF message (CC + TLV + record + terminator).
+
+    Returns raw bytes ready to write to NTAG starting at page 4.
+    """
+    payload = _build_payload(spool)
     mime_type = OPENTAG3D_MIME_TYPE
 
     # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
+    # Type length = 21
+    # Payload length = 102 (fits in SR single byte)
     record_header = bytes([0xD2, len(mime_type), len(payload)])
     ndef_record = record_header + mime_type + payload
 
@@ -114,24 +94,10 @@ def _encode_ndef(payload: bytes) -> bytes:
     else:
         tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
 
+    # Capability Container (page 4)
     cc = bytes([0xE1, 0x10, 0x12, 0x00])
-    terminator = bytes([0xFE])
-    return cc + tlv + ndef_record + terminator
 
+    # Terminator TLV
+    terminator = bytes([0xFE])
 
-def encode_opentag3d(spool: Spool) -> bytes:
-    """Encode spool ORM object as OpenTag3D NDEF message.
-
-    Returns raw bytes ready to write to NTAG starting at page 4.
-    """
-    return _encode_ndef(_build_payload(spool))
-
-
-def encode_opentag3d_from_mapped(mapped: MappedSpoolFields) -> bytes:
-    """Encode a Spoolman-mapped spool dict as OpenTag3D NDEF message.
-
-    Accepts the dict produced by ``_map_spoolman_spool`` (or any dict
-    with the same field names).  Returns raw bytes ready to write to
-    NTAG starting at page 4.
-    """
-    return _encode_ndef(_build_payload_from_dict(mapped))
+    return cc + tlv + ndef_record + terminator

+ 0 - 21
backend/app/services/spool_tag_matcher.py

@@ -538,27 +538,6 @@ async def auto_assign_spool(
                     ams_id,
                     tray_id,
                 )
-            elif tray is not None:
-                # No stored K-profile: fall back to the slot's current live cali_idx
-                # so the printer keeps its existing calibration selection.
-                live_cali_idx = tray.get("cali_idx")
-                if live_cali_idx is not None and live_cali_idx >= 0:
-                    cali_filament_id = spool.slicer_filament or tray_info_idx or ""
-                    client.extrusion_cali_sel(
-                        ams_id=ams_id,
-                        tray_id=tray_id,
-                        cali_idx=live_cali_idx,
-                        filament_id=cali_filament_id,
-                        nozzle_diameter=nozzle_diameter,
-                    )
-                    logger.info(
-                        "No stored K-profile for spool %d on printer %d AMS%d-T%d — preserved live cali_idx=%d",
-                        spool.id,
-                        printer_id,
-                        ams_id,
-                        tray_id,
-                        live_cali_idx,
-                    )
 
             logger.info(
                 "Auto-assigned spool %d to printer %d AMS%d-T%d (RFID match)",

+ 22 - 89
backend/app/services/spoolbuddy_ssh.py

@@ -15,7 +15,6 @@ entries for root). asyncssh does all of its work in-process.
 import asyncio
 import logging
 import os
-import shlex
 from pathlib import Path
 
 import asyncssh
@@ -140,61 +139,45 @@ async def _run_ssh_command(
     ip: str,
     command: str,
     private_key: Path,
-    *,
-    known_hosts: "asyncssh.SSHKnownHosts | None" = None,
     timeout: int = 60,
-) -> tuple[int, str, str, str | None]:
+) -> tuple[int, str, str]:
     """Execute a command on a SpoolBuddy device via SSH.
 
     Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring
     for the Docker/PUID rationale.
 
-    Returns (returncode, stdout, stderr, observed_host_key).
-    observed_host_key is non-None only on a successful connection when known_hosts=None
-    was passed. Callers are responsible for also checking whether a stored key already
-    exists before persisting — use `observed_key and not stored_host_key` not just
-    `observed_key is not None`.
-    On connection failure rc=255; on timeout rc=-1.
+    Returns (returncode, stdout, stderr). On connection failure the return
+    code is 255 (matching `ssh`'s own convention) and stderr carries the
+    asyncssh error message. On timeout the return code is -1.
     """
-    observed_host_key: str | None = None
     try:
         async with asyncio.timeout(timeout):
             async with asyncssh.connect(
                 host=ip,
                 username=SSH_USER,
                 client_keys=[str(private_key)],
-                known_hosts=known_hosts,
+                known_hosts=None,  # equivalent to StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null
                 config=[],  # do not load ~/.ssh/config — HOME may not resolve under arbitrary Docker PUIDs
                 connect_timeout=10,
             ) as conn:
-                if known_hosts is None:
-                    # TOFU first-use: capture the host key for storage
-                    server_key = conn.get_server_host_key()
-                    if server_key:
-                        observed_host_key = server_key.export_public_key("openssh").decode().strip()
                 result = await conn.run(command, check=False)
-    except asyncssh.HostKeyNotVerifiable:
-        logger.error("SSH host key mismatch for %s — possible MITM attack", ip)
-        return 255, "", "Host key mismatch — verify device identity before retrying", None
     except TimeoutError:
-        return -1, "", "SSH command timed out", None
+        return -1, "", "SSH command timed out"
     except (asyncssh.Error, OSError) as exc:
-        return 255, "", str(exc), None
+        return 255, "", str(exc)
 
     stdout = result.stdout if isinstance(result.stdout, str) else (result.stdout or b"").decode(errors="replace")
     stderr = result.stderr if isinstance(result.stderr, str) else (result.stderr or b"").decode(errors="replace")
     # asyncssh's exit_status is None when the remote closed without setting one
     returncode = result.exit_status if result.exit_status is not None else 0
-    return returncode, stdout, stderr, observed_host_key
+    return returncode, stdout, stderr
 
 
 async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None:
     """SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
 
     Updates device.update_status/update_message in the DB and broadcasts
-    progress via WebSocket at each step.  Host key verification uses TOFU:
-    the device's SSH public key is stored on first connect and verified on
-    all subsequent connections.
+    progress via WebSocket at each step.
     """
     from sqlalchemy import select
 
@@ -204,27 +187,6 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
 
     install_path = install_path or DEFAULT_INSTALL_PATH
     branch = detect_current_branch()
-    safe_branch = shlex.quote(branch)
-    safe_path = shlex.quote(install_path)
-
-    # Load the stored SSH host key for TOFU verification
-    stored_host_key: str | None = None
-    async with async_session() as db:
-        result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
-        dev = result.scalar_one_or_none()
-        if dev:
-            stored_host_key = dev.ssh_host_key
-
-    known_hosts: asyncssh.SSHKnownHosts | None = None
-    if stored_host_key:
-        try:
-            known_hosts = asyncssh.import_known_hosts(f"{ip_address} {stored_host_key}\n".encode())
-        except (ValueError, asyncssh.Error) as exc:
-            logger.warning(
-                "Could not parse stored SSH host key for %s, falling back to TOFU: %s",
-                device_id,
-                exc,
-            )
 
     async def _update_progress(status: str, message: str) -> None:
         """Update device status in DB and broadcast via WebSocket."""
@@ -252,40 +214,17 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
 
         # Step 1: Test SSH connectivity
         await _update_progress("updating", "Connecting via SSH...")
-        rc, _, stderr, observed_key = await _run_ssh_command(
-            ip_address, "echo ok", private_key, known_hosts=known_hosts
-        )
+        rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key)
         if rc != 0:
             await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
             return
 
-        # TOFU: persist host key on first successful connect
-        if observed_key and not stored_host_key:
-            async with async_session() as db:
-                result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
-                d = result.scalar_one_or_none()
-                if d:
-                    d.ssh_host_key = observed_key
-                    await db.commit()
-            logger.info("TOFU: stored SSH host key for SpoolBuddy %s", device_id)
-            try:
-                known_hosts = asyncssh.import_known_hosts(f"{ip_address} {observed_key}\n".encode())
-            except (ValueError, asyncssh.Error) as exc:
-                logger.error(
-                    "TOFU: could not parse just-stored host key for %s; "
-                    "remaining SSH steps in this run will not verify host key: %s",
-                    device_id,
-                    exc,
-                )
-                known_hosts = None
-
         # Step 2: Git fetch
         await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
-            f"cd {safe_path} && git -c safe.directory={safe_path} fetch origin {safe_branch}",
+            f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}",
             private_key,
-            known_hosts=known_hosts,
             timeout=120,
         )
         if rc != 0:
@@ -294,12 +233,11 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
 
         # Step 3: Git checkout + reset
         await _update_progress("updating", "Applying update...")
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
-            f"cd {safe_path} && git -c safe.directory={safe_path} checkout {safe_branch} "
-            f"&& git -c safe.directory={safe_path} reset --hard origin/{safe_branch}",
+            f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} "
+            f"&& git -c safe.directory={install_path} reset --hard origin/{branch}",
             private_key,
-            known_hosts=known_hosts,
         )
         if rc != 0:
             await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
@@ -307,12 +245,11 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
 
         # Step 4: Install dependencies
         await _update_progress("updating", "Installing dependencies...")
-        venv_pip = shlex.quote(f"{install_path}/spoolbuddy/venv/bin/pip")
-        rc, _, stderr, _ = await _run_ssh_command(
+        venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip"
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
             f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
             private_key,
-            known_hosts=known_hosts,
             timeout=120,
         )
         if rc != 0:
@@ -320,11 +257,10 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
 
         # Step 5: Restart daemon
         await _update_progress("updating", "Restarting daemon...")
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
             "sudo /usr/bin/systemctl restart spoolbuddy.service",
             private_key,
-            known_hosts=known_hosts,
         )
         if rc != 0:
             await _update_progress("error", f"Service restart failed: {stderr[:200]}")
@@ -336,20 +272,17 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
             ip_address,
             "sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true",
             private_key,
-            known_hosts=known_hosts,
         )
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
             "sudo /usr/bin/systemctl restart getty@tty1.service",
             private_key,
-            known_hosts=known_hosts,
         )
         if rc != 0:
             logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
 
         logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch)
-        await _update_progress("complete", f"Updated to {branch}")
 
-    except Exception:
-        logger.exception("SpoolBuddy %s: SSH update failed", device_id)
-        await _update_progress("error", "Update failed due to an internal error")
+    except Exception as e:
+        logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e)
+        await _update_progress("error", f"Update failed: {str(e)[:200]}")

File diff suppressed because it is too large
+ 312 - 444
backend/app/services/spoolman.py


+ 5 - 21
backend/app/services/spoolman_tracking.py

@@ -12,13 +12,7 @@ from sqlalchemy import delete, select
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
-from backend.app.services.spoolman import (
-    SpoolmanClientError,
-    SpoolmanNotFoundError,
-    SpoolmanUnavailableError,
-    get_spoolman_client,
-    init_spoolman_client,
-)
+from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
 
 logger = logging.getLogger(__name__)
 
@@ -77,7 +71,6 @@ def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id
     """
     tray_uuid = str(tray_info.get("tray_uuid", "") or "")
     tag_uid = str(tray_info.get("tag_uid", "") or "")
-
     if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid):
         return tray_uuid
     if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
@@ -319,16 +312,9 @@ async def _get_spoolman_client_with_fallback():
 
             spoolman_url = await get_setting(db, "spoolman_url")
             if spoolman_url:
-                try:
-                    client = await init_spoolman_client(spoolman_url)
-                except ValueError as exc:
-                    logger.warning("Spoolman URL %r rejected by SSRF guard: %s", spoolman_url, exc)
-                    return None
+                client = await init_spoolman_client(spoolman_url)
 
-    if not client:
-        return None
-    if not await client.health_check():
-        logger.warning("Spoolman health check failed; skipping usage reporting")
+    if not client or not await client.health_check():
         return None
 
     return client
@@ -377,12 +363,10 @@ async def _report_spool_usage_for_slots(
             logger.debug("[SPOOLMAN] Slot %s: no spool for tag %s...", slot_id, spool_tag[:16])
             continue
 
-        try:
-            await client.use_spool(spool["id"], grams_used)
+        result = await client.use_spool(spool["id"], grams_used)
+        if result:
             logger.info("[SPOOLMAN] %s: slot %s: %sg -> spool %s", method_label, slot_id, grams_used, spool["id"])
             spools_updated += 1
-        except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
-            logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool["id"], exc)
 
     return spools_updated
 

+ 1 - 32
backend/app/utils/filament_ids.py

@@ -1,4 +1,4 @@
-"""Utility functions for converting between filament_id and setting_id formats, and shared filament constants.
+"""Utility functions for converting between filament_id and setting_id formats.
 
 Bambu printers use two ID formats for filament presets:
   - **filament_id** (aka tray_info_idx): e.g. "GFL05", "GFG02", "GFA00"
@@ -10,37 +10,6 @@ The only difference for official Bambu filaments is an "S" inserted after "GF".
 User presets (starting with "P") use the same ID in both contexts.
 """
 
-MATERIAL_TEMPS: dict[str, tuple[int, int]] = {
-    "PLA": (190, 230),
-    "PETG": (220, 260),
-    "ABS": (240, 270),
-    "ASA": (240, 270),
-    "TPU": (200, 240),
-    "PA": (260, 290),
-    "PC": (250, 280),
-    "PVA": (190, 210),
-    "PLA-CF": (210, 240),
-    "PETG-CF": (240, 270),
-    "PA-CF": (270, 300),
-}
-
-GENERIC_FILAMENT_IDS: dict[str, str] = {
-    "PLA": "GFL99",
-    "PETG": "GFG99",
-    "ABS": "GFB99",
-    "ASA": "GFB98",
-    "PC": "GFC99",
-    "PA": "GFN99",
-    "NYLON": "GFN99",
-    "TPU": "GFU99",
-    "PVA": "GFS99",
-    "HIPS": "GFS98",
-    "PLA-CF": "GFL98",
-    "PETG-CF": "GFG98",
-    "PA-CF": "GFN98",
-    "PETG HF": "GFG96",
-}
-
 
 def filament_id_to_setting_id(filament_id: str) -> str:
     """Convert filament_id → setting_id (e.g. "GFL05" → "GFSL05").

+ 0 - 2
backend/tests/conftest.py

@@ -94,8 +94,6 @@ async def test_engine():
         spool_k_profile,
         spool_usage_history,
         spoolbuddy_device,
-        spoolman_k_profile,
-        spoolman_slot_assignment,
         user,
         user_email_pref,
         user_otp_code,

+ 0 - 161
backend/tests/integration/test_auth_apikey_rbac.py

@@ -1,161 +0,0 @@
-"""Integration tests for API key RBAC enforcement (security fix C1)."""
-
-import pytest
-from httpx import AsyncClient
-
-
-@pytest.fixture
-async def api_key_data(async_client: AsyncClient, db_session):
-    """Create an API key and return its full key value."""
-    from backend.app.core.auth import generate_api_key
-    from backend.app.models.api_key import APIKey
-
-    full_key, key_hash, key_prefix = generate_api_key()
-    api_key = APIKey(
-        name="test-key",
-        key_hash=key_hash,
-        key_prefix=key_prefix,
-        can_queue=True,
-        can_control_printer=True,
-        can_read_status=True,
-        enabled=True,
-    )
-    db_session.add(api_key)
-    await db_session.commit()
-    return full_key
-
-
-@pytest.fixture
-async def spoolman_settings(db_session):
-    from backend.app.models.settings import Settings
-
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-    await db_session.commit()
-
-
-class TestApiKeyRbacDenied:
-    """API keys must be refused for admin-only endpoints."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_api_key_cannot_access_settings_update_endpoint(
-        self, async_client: AsyncClient, db_session, api_key_data
-    ):
-        """API key must not be usable for settings:update endpoints (C1)."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="auth_enabled", value="true"))
-        await db_session.commit()
-
-        resp = await async_client.put(
-            "/api/v1/settings/",
-            json={},
-            headers={"X-API-Key": api_key_data},
-        )
-        assert resp.status_code == 403
-        assert "administrative operations" in resp.json()["detail"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_api_key_bearer_cannot_access_settings_update(
-        self, async_client: AsyncClient, db_session, api_key_data
-    ):
-        """Bearer bb_ API key must also be refused for settings:update (C1)."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="auth_enabled", value="true"))
-        await db_session.commit()
-
-        resp = await async_client.put(
-            "/api/v1/settings/",
-            json={},
-            headers={"Authorization": f"Bearer {api_key_data}"},
-        )
-        assert resp.status_code == 403
-        assert "administrative operations" in resp.json()["detail"]
-
-
-class TestApiKeyRbacAllowed:
-    """API keys must still work for non-admin endpoints."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_api_key_can_access_inventory_read(
-        self, async_client: AsyncClient, db_session, api_key_data, spoolman_settings
-    ):
-        """API key must be accepted for inventory:read endpoints (C1)."""
-        from unittest.mock import AsyncMock, MagicMock, patch
-
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="auth_enabled", value="true"))
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.base_url = "http://localhost:7912"
-        mock_client.health_check = AsyncMock(return_value=True)
-        mock_client.get_all_spools = AsyncMock(return_value=[])
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.get(
-                "/api/v1/spoolman/inventory/spools",
-                headers={"X-API-Key": api_key_data},
-            )
-        assert resp.status_code == 200
-
-
-class TestApiKeyDenylistIntegrity:
-    """Drift-detection: assert that admin-tier permissions remain in the denylist."""
-
-    def test_admin_permissions_are_denied_for_api_keys(self):
-        """All known admin-tier permissions must be in _APIKEY_DENIED_PERMISSIONS (H1 guard)."""
-        from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
-        from backend.app.core.permissions import Permission
-
-        expected_denied = {
-            # SETTINGS_READ is intentionally NOT denied — SpoolBuddy kiosk reads
-            # settings via API key (e.g. to sync the UI language).
-            Permission.SETTINGS_UPDATE,
-            Permission.SETTINGS_BACKUP,
-            Permission.SETTINGS_RESTORE,
-            Permission.USERS_READ,
-            Permission.USERS_CREATE,
-            Permission.USERS_UPDATE,
-            Permission.USERS_DELETE,
-            Permission.GROUPS_READ,
-            Permission.GROUPS_CREATE,
-            Permission.GROUPS_UPDATE,
-            Permission.GROUPS_DELETE,
-            Permission.API_KEYS_READ,
-            Permission.API_KEYS_CREATE,
-            Permission.API_KEYS_UPDATE,
-            Permission.API_KEYS_DELETE,
-            Permission.GITHUB_BACKUP,
-            Permission.GITHUB_RESTORE,
-            Permission.FIRMWARE_UPDATE,
-        }
-        missing = expected_denied - _APIKEY_DENIED_PERMISSIONS
-        assert not missing, (
-            f"Admin-tier permissions not in API key denylist (add them to _APIKEY_DENIED_PERMISSIONS): {missing}"
-        )
-
-    def test_operational_permissions_are_allowed_for_api_keys(self):
-        """Core operational permissions must NOT be in the denylist."""
-        from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
-        from backend.app.core.permissions import Permission
-
-        expected_allowed = {
-            Permission.INVENTORY_READ,
-            Permission.INVENTORY_CREATE,
-            Permission.INVENTORY_UPDATE,
-            Permission.PRINTERS_READ,
-            Permission.PRINTERS_CONTROL,
-            Permission.ARCHIVES_READ,
-            # SpoolBuddy kiosk reads settings (e.g. language) via API key — must stay allowed.
-            Permission.SETTINGS_READ,
-        }
-        incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS
-        assert not incorrectly_denied, f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"

+ 0 - 104
backend/tests/integration/test_inventory_assign.py

@@ -470,110 +470,6 @@ class TestAssignSpoolPresetMapping:
         assert presets["0"]["preset_name"] == "Overture PLA Matte"
 
 
-class TestAssignSpoolLiveCaliIdx:
-    """P9-TEST-BE-3: assign_spool falls back to live tray cali_idx when no K-profile stored."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_kprofile_uses_live_cali_idx(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """When no KProfile row exists, live tray cali_idx is sent via extrusion_cali_sel."""
-        printer = await printer_factory()
-        spool = await spool_factory()
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        tray_data = {
-            "id": 1,
-            "cali_idx": 42,
-            "tray_color": "FF0000FF",
-            "tray_type": "PLA",
-            "tray_sub_brands": "PLA Basic",
-            "tray_id_name": "GFL99",
-        }
-        status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
-
-        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = status
-
-            response = await async_client.post(
-                "/api/v1/inventory/assignments",
-                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 1},
-            )
-
-        assert response.status_code == 200
-        mock_client.extrusion_cali_sel.assert_called_once()
-        call_kwargs = mock_client.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 42
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_kprofile_no_live_cali_idx_nothing_sent(
-        self, async_client: AsyncClient, printer_factory, spool_factory
-    ):
-        """When tray has no cali_idx, extrusion_cali_sel is not called."""
-        printer = await printer_factory()
-        spool = await spool_factory()
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        tray_data = {
-            "id": 0,
-            "cali_idx": None,
-            "tray_color": "FF0000FF",
-            "tray_type": "PLA",
-            "tray_sub_brands": "PLA Basic",
-            "tray_id_name": "GFL99",
-        }
-        status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
-
-        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = status
-
-            response = await async_client.post(
-                "/api/v1/inventory/assignments",
-                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
-            )
-
-        assert response.status_code == 200
-        mock_client.extrusion_cali_sel.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_negative_live_cali_idx_not_sent(self, async_client: AsyncClient, printer_factory, spool_factory):
-        """A negative live cali_idx (-1) is invalid and must not be sent."""
-        printer = await printer_factory()
-        spool = await spool_factory()
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        tray_data = {
-            "id": 0,
-            "cali_idx": -1,
-            "tray_color": "FF0000FF",
-            "tray_type": "PLA",
-            "tray_sub_brands": "PLA Basic",
-            "tray_id_name": "GFL99",
-        }
-        status = _make_mock_status(ams_data=[{"id": 0, "tray": [tray_data]}])
-
-        with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = status
-
-            response = await async_client.post(
-                "/api/v1/inventory/assignments",
-                json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
-            )
-
-        assert response.status_code == 200
-        mock_client.extrusion_cali_sel.assert_not_called()
-
-
 class TestAssignSpoolEmptySlotPreConfig:
     """SpoolBuddy primary workflow: weigh-then-assign before the spool is in the AMS.
 

+ 1 - 1272
backend/tests/integration/test_printers_api.py

@@ -3,11 +3,10 @@
 Tests the full request/response cycle for /api/v1/printers/ endpoints.
 """
 
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import MagicMock, patch
 
 import pytest
 from httpx import AsyncClient
-from sqlalchemy import select
 
 
 class TestPrintersAPI:
@@ -1579,1273 +1578,3 @@ class TestClearHMSErrorsAPI:
 
             assert response.status_code == 500
             assert "failed" in response.json()["detail"].lower()
-
-
-def _build_h2d_state(*, ams_id: int = 0, tray_id: int = 2, cali_idx: int = 5):
-    """Build a MagicMock PrinterState for an H2D printer with a single BL spool tray.
-
-    Used by both TestApplyPaAfterRefresh (Phase 13 P13-T-BE-1) and the K-profile
-    persistence tests below. The tray data passes is_bambu_tag (32-char non-zero
-    tray_uuid + non-empty tray_info_idx).
-    """
-    nozzle = MagicMock(nozzle_diameter="0.4")
-    state = MagicMock()
-    state.nozzles = [nozzle]
-    state.ams_extruder_map = {"0": 0}
-    state.raw_data = {
-        "ams": [
-            {
-                "id": ams_id,
-                "tray": [
-                    {
-                        "id": tray_id,
-                        "tray_type": "PLA",
-                        "tag_uid": "AABBCC1122334400",
-                        "tray_uuid": "11223344556677880011223344556677",
-                        "tray_info_idx": "GFL05",
-                        "cali_idx": cali_idx,
-                    }
-                ],
-            }
-        ]
-    }
-    return state
-
-
-def _patch_async_session_to(db_session):
-    """Patch backend.app.core.database.async_session so calls inside the function
-    under test reuse the test fixture's db_session.
-
-    `_apply_pa_after_refresh` lazy-imports `from backend.app.core.database import
-    async_session` at runtime (line 2849). When we patch the source module
-    before the call, the lazy import picks up the patched object.
-
-    Returns the patch context manager; use as `with _patch_async_session_to(db_session):`.
-    Pattern verified against test_print_lifecycle.py:38-42.
-    """
-    cm = AsyncMock()
-    cm.__aenter__ = AsyncMock(return_value=db_session)
-    cm.__aexit__ = AsyncMock(return_value=None)
-    return patch("backend.app.core.database.async_session", return_value=cm)
-
-
-class TestApplyPaAfterRefresh:
-    """Phase 13 P13-T-BE-1: _apply_pa_after_refresh K-profile cascade.
-
-    Verifies the 3-stage cascade (local SpoolKProfile → Spoolman SpoolmanKProfile
-    → live tray.cali_idx fallback) and the Bug A regression (kp.extruder, not
-    kp.extruder_id, after the Phase 13 P13-2a fix).
-
-    `_apply_pa_after_refresh` is a free function spawned via asyncio.create_task
-    from the /ams-refresh endpoint. Tests call it directly because awaiting the
-    spawned task in an HTTP test would require sleeping past the 5-second guard
-    that delays MQTT until RFID re-read finishes.
-    """
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_local_kp_match_sends_stored_cali_idx(self, db_session, printer_factory):
-        """Local SpoolAssignment + matching SpoolKProfile → stored cali_idx wins."""
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_assignment import SpoolAssignment
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory()
-        spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolAssignment(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=2,
-            )
-        )
-        db_session.add(
-            SpoolKProfile(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                extruder=0,
-                nozzle_diameter="0.4",
-                k_value=0.025,
-                cali_idx=42,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        mock_client.extrusion_cali_sel.assert_called_once()
-        kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
-        assert kwargs["cali_idx"] == 42  # stored profile, not 5 (live)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_local_no_kp_uses_live_cali_idx(self, db_session, printer_factory):
-        """Local SpoolAssignment but no matching SpoolKProfile → live cali_idx (Stage 3)."""
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_assignment import SpoolAssignment
-
-        printer = await printer_factory()
-        spool = Spool(material="PLA")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolAssignment(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=2,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        mock_client.extrusion_cali_sel.assert_called_once()
-        kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
-        assert kwargs["cali_idx"] == 5  # live tray.cali_idx fallback
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_kp_when_no_local(self, db_session, printer_factory):
-        """No local assignment + Spoolman SlotAssignment + SpoolmanKProfile → Spoolman cali_idx."""
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        printer = await printer_factory()
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=2,
-                spoolman_spool_id=99,
-            )
-        )
-        db_session.add(
-            SpoolmanKProfile(
-                spoolman_spool_id=99,
-                printer_id=printer.id,
-                extruder=0,
-                nozzle_diameter="0.4",
-                k_value=0.030,
-                cali_idx=77,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        mock_client.extrusion_cali_sel.assert_called_once()
-        kwargs = mock_client.extrusion_cali_sel.call_args.kwargs
-        assert kwargs["cali_idx"] == 77  # Spoolman stored profile
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_no_kp_uses_live(self, db_session, printer_factory):
-        """Spoolman SlotAssignment but no SpoolmanKProfile → live cali_idx (Stage 3)."""
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        printer = await printer_factory()
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=2,
-                spoolman_spool_id=99,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        mock_client.extrusion_cali_sel.assert_called_once()
-        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_assignment_uses_live(self, db_session, printer_factory):
-        """No assignment of any kind + live cali_idx >= 0 → live fallback."""
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-
-        printer = await printer_factory()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        mock_client.extrusion_cali_sel.assert_called_once()
-        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 5
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_negative_live_cali_idx_skipped(self, db_session, printer_factory):
-        """No assignment + live cali_idx=-1 → no MQTT call (invalid live value)."""
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-
-        printer = await printer_factory()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=-1)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        mock_client.extrusion_cali_sel.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_assignment_no_live_cali_idx_no_call(self, db_session, printer_factory):
-        """No assignment of any kind AND no live cali_idx in tray → no MQTT call.
-
-        Distinct from test_negative_live_cali_idx_skipped: that test has
-        cali_idx=-1 in raw_data; this one omits the field entirely (returns
-        None from .get("cali_idx")). Both must result in no MQTT call.
-        """
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-
-        printer = await printer_factory()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        # State with NO cali_idx field on tray at all
-        nozzle = MagicMock(nozzle_diameter="0.4")
-        state = MagicMock()
-        state.nozzles = [nozzle]
-        state.ams_extruder_map = {"0": 0}
-        state.raw_data = {
-            "ams": [
-                {
-                    "id": 0,
-                    "tray": [
-                        {
-                            "id": 2,
-                            "tray_type": "PLA",
-                            "tag_uid": "AABBCC1122334400",
-                            "tray_uuid": "11223344556677880011223344556677",
-                            "tray_info_idx": "GFL05",
-                            # cali_idx field intentionally omitted
-                        }
-                    ],
-                }
-            ]
-        }
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        mock_client.extrusion_cali_sel.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_extruder_mismatch_uses_kp_as_fallback(self, db_session, printer_factory):
-        """K-profile for extruder=1 but slot is extruder=0 → no exact match,
-        but the kp is used as extruder-agnostic fallback rather than dropped.
-
-        Hard-skipping on extruder mismatch was the previous behavior; in
-        practice it caused stored K-profiles to be silently ignored whenever
-        the AMS-extruder mapping had shifted (or when only one of the two
-        extruders was ever calibrated for a given spool). The cascade now
-        prefers an exact extruder match but falls back to any matching kp
-        for the same printer + nozzle.
-        """
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_assignment import SpoolAssignment
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory()
-        spool = Spool(material="PLA")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolAssignment(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=2,
-            )
-        )
-        # K-profile is for extruder=1, but slot's ams_extruder_map["0"]=0
-        db_session.add(
-            SpoolKProfile(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                extruder=1,
-                nozzle_diameter="0.4",
-                k_value=0.025,
-                cali_idx=42,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        # No exact extruder match, but the stored kp wins as the
-        # extruder-agnostic fallback over live cali_idx=5.
-        mock_client.extrusion_cali_sel.assert_called_once()
-        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_extruder_exact_match_preferred_over_fallback(
-        self,
-        db_session,
-        printer_factory,
-    ):
-        """When two kp rows exist, one with matching extruder and one without,
-        the exact-extruder kp wins (extruder-agnostic fallback only fires when
-        no exact match exists).
-        """
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_assignment import SpoolAssignment
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory()
-        spool = Spool(material="PLA")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolAssignment(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=2,
-            )
-        )
-        # Two kp rows: extruder=1 (mismatch w/ slot extruder=0) and extruder=0 (exact)
-        db_session.add(
-            SpoolKProfile(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                extruder=1,
-                nozzle_diameter="0.4",
-                k_value=0.030,
-                cali_idx=99,
-            )
-        )
-        db_session.add(
-            SpoolKProfile(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                extruder=0,
-                nozzle_diameter="0.4",
-                k_value=0.025,
-                cali_idx=42,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        # Exact-extruder=0 kp wins (cali_idx=42), not the extruder=1 fallback (99)
-        mock_client.extrusion_cali_sel.assert_called_once()
-        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_regression_bug_a_kp_extruder_attr(self, db_session, printer_factory):
-        """Regression test for Phase 13 P13-2a Bug A.
-
-        Pre-fix Z.2910 used `kp.extruder_id` (AttributeError on SpoolKProfile,
-        silently swallowed by outer try/except). On dual-nozzle printers with
-        slot_extruder != None this caused the K-profile match loop to crash.
-        After P13-2a the field name is correct: `kp.extruder`.
-        """
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_assignment import SpoolAssignment
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory()
-        spool = Spool(material="PLA")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolAssignment(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=2,
-            )
-        )
-        # extruder=0 matches slot_extruder=0 (from ams_extruder_map={"0":0})
-        db_session.add(
-            SpoolKProfile(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                extruder=0,
-                nozzle_diameter="0.4",
-                k_value=0.025,
-                cali_idx=42,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        # If Bug A regressed (kp.extruder_id), the loop would AttributeError → silent fail
-        # → no extrusion_cali_sel call. Post-fix the loop matches and sends cali_idx=42.
-        mock_client.extrusion_cali_sel.assert_called_once()
-        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_fallback_finds_spool_when_assignment_missing(
-        self,
-        db_session,
-        printer_factory,
-    ):
-        """Stage 1b regression for the maintainer's #2 reproducer on H2D:
-        reset slot, trigger re-read → slot ends up on the default K-profile
-        instead of the spool's stored profile.
-
-        Setup mirrors the bug:
-          - Spool has tray_uuid set (the RFID tag was registered earlier).
-          - SpoolKProfile exists for that spool with cali_idx=42.
-          - NO SpoolAssignment row — the reset deleted it before the re-read
-            triggered _apply_pa_after_refresh, and tag-auto-detect has not
-            re-created it yet within the 5 s sleep window.
-          - Live tray.cali_idx=5 (firmware-default after the RFID re-read).
-
-        Without Stage 1b the cascade falls through to Stage 3 and re-asserts
-        the firmware-default cali_idx=5. With Stage 1b it locates the spool by
-        the live tray's tray_uuid and applies the stored cali_idx=42.
-        """
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory()
-        # Spool with tray_uuid matching the one _build_h2d_state puts on the tray
-        spool = Spool(
-            material="PLA",
-            color_name="Red",
-            rgba="FF0000FF",
-            tray_uuid="11223344556677880011223344556677",
-            tag_uid="AABBCC1122334400",
-        )
-        db_session.add(spool)
-        await db_session.flush()
-        # K-profile is bound to the spool, not to a slot
-        db_session.add(
-            SpoolKProfile(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                extruder=0,
-                nozzle_diameter="0.4",
-                k_value=0.025,
-                cali_idx=42,
-            )
-        )
-        # NOTE: deliberately no SpoolAssignment — that's the bug condition.
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=5)
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        # Stage 1b should match the spool by tray_uuid → stored cali_idx=42 wins
-        # over live cali_idx=5. Pre-fix this would have been 5 (firmware default).
-        mock_client.extrusion_cali_sel.assert_called_once()
-        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_fallback_matches_by_tag_uid_when_uuid_zero(
-        self,
-        db_session,
-        printer_factory,
-    ):
-        """Stage 1b: when tray_uuid is the zero sentinel but tag_uid is real,
-        match by tag_uid. Older firmwares occasionally report a zero tray_uuid
-        right after RFID re-read while the tag_uid is already populated."""
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory()
-        # Spool indexed by tag_uid, not tray_uuid
-        spool = Spool(material="PLA", tag_uid="AABBCC1122334400")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolKProfile(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                extruder=0,
-                nozzle_diameter="0.4",
-                k_value=0.025,
-                cali_idx=99,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        # Build a state where the tray reports a real tag_uid but a zero tray_uuid
-        # while still passing is_bambu_tag (tag_uid + tray_info_idx is sufficient).
-        state = _build_h2d_state(cali_idx=5)
-        state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        mock_client.extrusion_cali_sel.assert_called_once()
-        assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 99
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_fallback_skipped_when_zero_sentinels(
-        self,
-        db_session,
-        printer_factory,
-    ):
-        """Stage 1b: when both tray_uuid and tag_uid are zero sentinels, the
-        fallback must not match any spool (would otherwise pick up an
-        unrelated spool created with empty/zero tag fields). Falls through
-        to Stage 3 live cali_idx as before.
-        """
-        from backend.app.api.routes.printers import _apply_pa_after_refresh
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory()
-        # Decoy spool with no tag info — must NOT match
-        spool = Spool(material="PLA")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolKProfile(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                extruder=0,
-                nozzle_diameter="0.4",
-                k_value=0.025,
-                cali_idx=42,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.extrusion_cali_sel = MagicMock(return_value=True)
-        state = _build_h2d_state(cali_idx=7)
-        # Force both tag fields to the zero sentinels but keep tray_info_idx
-        # so is_bambu_tag still passes (preset present)
-        state.raw_data["ams"][0]["tray"][0]["tag_uid"] = "0000000000000000"
-        state.raw_data["ams"][0]["tray"][0]["tray_uuid"] = "00000000000000000000000000000000"
-
-        with (
-            patch("backend.app.api.routes.printers.asyncio.sleep", AsyncMock()),
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            _patch_async_session_to(db_session),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = state
-            # is_bambu_tag actually rejects both-zero + only-preset, so the
-            # function returns early. We just want to confirm we didn't blow
-            # up scanning for a tag-fallback spool.
-            await _apply_pa_after_refresh(printer.id, ams_id=0, slot_id=2)
-
-        # is_bambu_tag short-circuits early when both UID and UUID are zero,
-        # so no MQTT call should fire and the decoy spool's cali_idx=42 must
-        # NOT leak through.
-        if mock_client.extrusion_cali_sel.called:
-            assert mock_client.extrusion_cali_sel.call_args.kwargs["cali_idx"] != 42
-
-
-class TestConfigureAmsSlotPersistsKProfile:
-    """Phase 13 P13-T-BE-2: configure_ams_slot persists K-profile to DB.
-
-    Pre-Phase-13 the endpoint sent extrusion_cali_sel via MQTT but never
-    recorded the choice in spool_k_profile / spoolman_k_profile, so the next
-    RFID re-read had no stored profile to apply.
-    """
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_writes_spoolman_kprofile_when_spoolman_assigned(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """SpoolmanSlotAssignment present → SpoolmanKProfile row created with cali_idx + k_value + name."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        printer = await printer_factory(model="H2D")
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=3,
-                spoolman_spool_id=216,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.extrusion_cali_set.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"0": 0}
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            response = await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/0/3/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "Bambu PLA Metal",
-                    "tray_color": "FF8800FF",
-                    "nozzle_temp_min": 220,
-                    "nozzle_temp_max": 240,
-                    "cali_idx": 5,
-                    "nozzle_diameter": "0.4",
-                    "k_value": 0.022,
-                },
-            )
-
-        assert response.status_code == 200
-        kp_result = await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
-        kp = kp_result.scalar_one_or_none()
-        assert kp is not None
-        assert kp.cali_idx == 5
-        assert kp.k_value == pytest.approx(0.022)
-        assert kp.extruder == 0
-        assert kp.nozzle_diameter == "0.4"
-        assert kp.name == "Bambu PLA Metal"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_writes_spool_kprofile_when_local_assigned(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """Local SpoolAssignment present → SpoolKProfile row created."""
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_assignment import SpoolAssignment
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory(model="H2D")
-        spool = Spool(material="PLA", color_name="Red", rgba="FF0000FF")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolAssignment(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=3,
-            )
-        )
-        await db_session.commit()
-        spool_id = spool.id
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.extrusion_cali_set.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"0": 0}
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            response = await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/0/3/configure",
-                params={
-                    "tray_info_idx": "PFUSdev01",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "Devil Design PLA",
-                    "tray_color": "FF0000FF",
-                    "nozzle_temp_min": 220,
-                    "nozzle_temp_max": 240,
-                    "cali_idx": 7,
-                    "nozzle_diameter": "0.4",
-                    "k_value": 0.028,
-                },
-            )
-
-        assert response.status_code == 200
-        kp_result = await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
-        kp = kp_result.scalar_one_or_none()
-        assert kp is not None
-        assert kp.cali_idx == 7
-        assert kp.k_value == pytest.approx(0.028)
-        assert kp.extruder == 0
-        assert kp.nozzle_diameter == "0.4"
-        assert kp.name == "Devil Design PLA"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_assignment_no_persist(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """No SpoolAssignment AND no SpoolmanSlotAssignment → no DB write, MQTT still sent."""
-        from backend.app.models.spool_k_profile import SpoolKProfile
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        printer = await printer_factory(model="H2D")
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"0": 0}
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            response = await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/0/3/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic",
-                    "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190,
-                    "nozzle_temp_max": 230,
-                    "cali_idx": 5,
-                    "k_value": 0.020,
-                },
-            )
-
-        assert response.status_code == 200
-        # MQTT sent (was successful), but no DB writes
-        mock_client.extrusion_cali_sel.assert_called_once()
-        local_count = (await db_session.execute(select(SpoolKProfile))).scalars().all()
-        sm_count = (await db_session.execute(select(SpoolmanKProfile))).scalars().all()
-        assert len(local_count) == 0
-        assert len(sm_count) == 0
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_negative_cali_idx_no_persist(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """cali_idx=-1 (no profile selected) → no DB write even when assignment exists."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        printer = await printer_factory(model="H2D")
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=3,
-                spoolman_spool_id=216,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.extrusion_cali_set.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"0": 0}
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            response = await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/0/3/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic",
-                    "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190,
-                    "nozzle_temp_max": 230,
-                    "cali_idx": -1,
-                    "k_value": 0.0,
-                },
-            )
-
-        assert response.status_code == 200
-        sm_kps = (
-            (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
-            .scalars()
-            .all()
-        )
-        assert len(sm_kps) == 0  # cali_idx=-1 means "no profile" — don't write
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_zero_cali_idx_persists(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """cali_idx=0 is the first valid profile slot (NOT a sentinel for missing)."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        printer = await printer_factory(model="H2D")
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=3,
-                spoolman_spool_id=216,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"0": 0}
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            response = await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/0/3/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic",
-                    "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190,
-                    "nozzle_temp_max": 230,
-                    "cali_idx": 0,
-                    "k_value": 0.020,
-                },
-            )
-
-        assert response.status_code == 200
-        kp = (
-            await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216))
-        ).scalar_one_or_none()
-        assert kp is not None
-        assert kp.cali_idx == 0  # explicitly testing 0 is valid
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_upsert_idempotent(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """Repeated POSTs update the same row (UNIQUE on spool_id+printer+extruder+nozzle_diameter)."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        printer = await printer_factory(model="H2D")
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=3,
-                spoolman_spool_id=216,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"0": 0}
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            # First call with cali_idx=5
-            await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/0/3/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic",
-                    "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190,
-                    "nozzle_temp_max": 230,
-                    "cali_idx": 5,
-                    "k_value": 0.020,
-                },
-            )
-            # Second call with cali_idx=10 (same slot/spool/extruder/nozzle)
-            await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/0/3/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Matte",
-                    "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190,
-                    "nozzle_temp_max": 230,
-                    "cali_idx": 10,
-                    "k_value": 0.025,
-                },
-            )
-
-        # Should be exactly ONE row (updated), not two
-        kps = (
-            (await db_session.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == 216)))
-            .scalars()
-            .all()
-        )
-        assert len(kps) == 1
-        assert kps[0].cali_idx == 10  # updated to most recent
-        assert kps[0].name == "PLA Matte"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_external_slot_extruder_inversion(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """ams_id=255 + tray_id=0 → kp.extruder=1 (ext-L); tray_id=1 → extruder=0 (ext-R)."""
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_assignment import SpoolAssignment
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory(model="H2D")
-        spool = Spool(material="PLA")
-        db_session.add(spool)
-        await db_session.flush()
-        # Note: SpoolmanSlotAssignment can't store ams_id=255 with tray_id=1
-        # under the ck_tray_id_range constraint (0-3 valid). External-slot
-        # K-profile persistence is therefore tested via local SpoolAssignment.
-        db_session.add(
-            SpoolAssignment(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                ams_id=255,
-                tray_id=0,
-            )
-        )
-        await db_session.commit()
-        spool_id = spool.id
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"0": 0}  # truthy so external-inversion path runs
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            response = await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/255/0/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic",
-                    "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190,
-                    "nozzle_temp_max": 230,
-                    "cali_idx": 5,
-                    "k_value": 0.020,
-                },
-            )
-
-        assert response.status_code == 200
-        kp = (
-            await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
-        ).scalar_one_or_none()
-        assert kp is not None
-        # tray_id=0 → extruder = 1 - 0 = 1
-        assert kp.extruder == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_dual_nozzle_extruder_persists(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """ams_extruder_map with extruder=1 → kp.extruder=1 persisted correctly."""
-        from backend.app.models.spool import Spool
-        from backend.app.models.spool_assignment import SpoolAssignment
-        from backend.app.models.spool_k_profile import SpoolKProfile
-
-        printer = await printer_factory(model="H2D")
-        spool = Spool(material="PLA")
-        db_session.add(spool)
-        await db_session.flush()
-        db_session.add(
-            SpoolAssignment(
-                spool_id=spool.id,
-                printer_id=printer.id,
-                ams_id=2,
-                tray_id=3,
-            )
-        )
-        await db_session.commit()
-        spool_id = spool.id
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"2": 1}  # AMS 2 is on extruder 1
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            response = await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/2/3/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic",
-                    "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190,
-                    "nozzle_temp_max": 230,
-                    "cali_idx": 5,
-                    "k_value": 0.020,
-                },
-            )
-
-        assert response.status_code == 200
-        kp = (
-            await db_session.execute(select(SpoolKProfile).where(SpoolKProfile.spool_id == spool_id))
-        ).scalar_one_or_none()
-        assert kp is not None
-        assert kp.extruder == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_db_error_does_not_fail_endpoint(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        printer_factory,
-    ):
-        """DB errors during K-profile persistence are best-effort — endpoint still returns 200.
-
-        Verifies the try/except wrap added in P13-3b: if DB upsert fails (e.g.
-        because the schema is out of sync, a constraint violation, or any
-        other transient error), the MQTT command was already sent successfully
-        so we shouldn't return 500 to the user. The error is logged and the
-        endpoint returns success.
-        """
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        printer = await printer_factory(model="H2D")
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=printer.id,
-                ams_id=0,
-                tray_id=3,
-                spoolman_spool_id=216,
-            )
-        )
-        await db_session.commit()
-
-        mock_client = MagicMock()
-        mock_client.ams_set_filament_setting.return_value = True
-        mock_client.extrusion_cali_sel.return_value = True
-        mock_client.request_status_update.return_value = True
-
-        mock_state = MagicMock()
-        mock_state.ams_extruder_map = {"0": 0}
-        mock_state.raw_data = {"ams": {"ams": []}}
-
-        # Force the K-profile persistence path to fail by patching the
-        # SpoolmanKProfile model class with a sentinel that raises when
-        # instantiated. The MQTT call has already happened by then, so the
-        # endpoint must catch and log without returning 500.
-        with (
-            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
-            patch(
-                "backend.app.models.spoolman_k_profile.SpoolmanKProfile",
-                side_effect=RuntimeError("Simulated DB error"),
-            ),
-        ):
-            mock_pm.get_client.return_value = mock_client
-            mock_pm.get_status.return_value = mock_state
-
-            response = await async_client.post(
-                f"/api/v1/printers/{printer.id}/slots/0/3/configure",
-                params={
-                    "tray_info_idx": "GFL05",
-                    "tray_type": "PLA",
-                    "tray_sub_brands": "PLA Basic",
-                    "tray_color": "FFFFFFFF",
-                    "nozzle_temp_min": 190,
-                    "nozzle_temp_max": 230,
-                    "cali_idx": 5,
-                    "k_value": 0.020,
-                },
-            )
-
-        # Endpoint returns success — MQTT was sent, K-profile failed silently
-        assert response.status_code == 200
-        # MQTT was indeed called
-        mock_client.extrusion_cali_sel.assert_called_once()

+ 0 - 184
backend/tests/integration/test_settings_api_key_scrubbing.py

@@ -1,184 +0,0 @@
-"""T-Gap 1 & T-Gap 2: Settings scrubbing for API-key callers + permission checks on RCE endpoints."""
-
-import pytest
-from httpx import AsyncClient
-
-
-@pytest.fixture
-async def api_key_with_settings_read(db_session):
-    """API key that has only INVENTORY_UPDATE permission (no SETTINGS_UPDATE)."""
-    from backend.app.core.auth import generate_api_key
-    from backend.app.models.api_key import APIKey
-
-    full_key, key_hash, key_prefix = generate_api_key()
-    api_key = APIKey(
-        name="read-only-key",
-        key_hash=key_hash,
-        key_prefix=key_prefix,
-        can_queue=False,
-        can_control_printer=False,
-        can_read_status=True,
-        enabled=True,
-    )
-    db_session.add(api_key)
-    await db_session.commit()
-    return full_key
-
-
-@pytest.fixture
-async def sensitive_settings(db_session):
-    """Seed all 5 sensitive settings fields with non-empty values."""
-    from backend.app.models.settings import Settings
-
-    # Keys listed separately so no single line pairs a credential-looking name
-    # with a string value (avoids false-positive secret scanner hits).
-    _credential_keys = [
-        "mqtt_password",
-        "ha_token",
-        "prometheus_token",
-        "virtual_printer_access_code",
-        "ldap_bind_password",
-    ]
-    for key in _credential_keys:
-        db_session.add(Settings(key=key, value="testdata"))
-    db_session.add(Settings(key="auth_enabled", value="false"))
-    await db_session.commit()
-
-
-class TestSettingsScrubForApiKey:
-    """T-Gap 1: GET /settings must blank all 5 sensitive fields for API-key callers."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_api_key_header_blanks_sensitive_fields(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        api_key_with_settings_read,
-        sensitive_settings,
-    ):
-        resp = await async_client.get(
-            "/api/v1/settings/",
-            headers={"X-API-Key": api_key_with_settings_read},
-        )
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["mqtt_password"] == ""
-        assert data["ha_token"] == ""
-        assert data["prometheus_token"] == ""
-        assert data["virtual_printer_access_code"] == ""
-        assert data["ldap_bind_password"] == ""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bearer_api_key_blanks_sensitive_fields(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        api_key_with_settings_read,
-        sensitive_settings,
-    ):
-        resp = await async_client.get(
-            "/api/v1/settings/",
-            headers={"Authorization": f"Bearer {api_key_with_settings_read}"},
-        )
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["mqtt_password"] == ""
-        assert data["ha_token"] == ""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unauthenticated_request_does_not_blank_fields(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        sensitive_settings,
-    ):
-        """Without auth, settings are returned as-is (auth disabled in test env)."""
-        resp = await async_client.get("/api/v1/settings/")
-        assert resp.status_code == 200
-        data = resp.json()
-        # Only ldap_bind_password is always blanked regardless of caller
-        assert data["ldap_bind_password"] == ""
-        # Other fields should NOT be blanked for non-API-key callers
-        assert data["mqtt_password"] != ""
-        assert data["ha_token"] != ""
-
-
-class TestRceEndpointPermissions:
-    """T-Gap 2: System command endpoints require SETTINGS_UPDATE permission."""
-
-    @pytest.fixture
-    async def auth_enabled(self, db_session):
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="auth_enabled", value="true"))
-        await db_session.commit()
-
-    @pytest.fixture
-    async def inventory_only_api_key(self, db_session):
-        """API key with ONLY inventory:update permission (no settings:update)."""
-        from backend.app.core.auth import generate_api_key
-        from backend.app.models.api_key import APIKey
-
-        full_key, key_hash, key_prefix = generate_api_key()
-        api_key = APIKey(
-            name="inventory-key",
-            key_hash=key_hash,
-            key_prefix=key_prefix,
-            can_queue=True,
-            can_control_printer=False,
-            can_read_status=True,
-            enabled=True,
-        )
-        db_session.add(api_key)
-        await db_session.commit()
-        return full_key
-
-    @pytest.fixture
-    async def spoolbuddy_device(self, db_session):
-        from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
-
-        device = SpoolBuddyDevice(
-            device_id="test-device-001",
-            hostname="spoolbuddy-01",
-            ip_address="192.168.1.50",
-        )
-        db_session.add(device)
-        await db_session.commit()
-        return device
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_system_command_requires_settings_update(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        auth_enabled,
-        inventory_only_api_key,
-        spoolbuddy_device,
-    ):
-        resp = await async_client.post(
-            f"/api/v1/spoolbuddy/devices/{spoolbuddy_device.device_id}/system/command",
-            json={"command": "reboot"},
-            headers={"X-API-Key": inventory_only_api_key},
-        )
-        assert resp.status_code == 403
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_trigger_update_requires_settings_update(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        auth_enabled,
-        inventory_only_api_key,
-        spoolbuddy_device,
-    ):
-        resp = await async_client.post(
-            f"/api/v1/spoolbuddy/devices/{spoolbuddy_device.device_id}/update",
-            json={},
-            headers={"X-API-Key": inventory_only_api_key},
-        )
-        assert resp.status_code == 403

+ 2 - 1178
backend/tests/integration/test_spoolbuddy.py

@@ -7,11 +7,9 @@ import pytest
 from httpx import AsyncClient
 from sqlalchemy.ext.asyncio import AsyncSession
 
-import backend.app.services.spoolbuddy_ssh  # noqa: F401 — ensures patch() can resolve the dotted path
 from backend.app.api.routes import spoolbuddy as spoolbuddy_routes
 from backend.app.models.spool import Spool
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
-from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
 
 API = "/api/v1/spoolbuddy"
 
@@ -577,7 +575,7 @@ class TestWriteTagEndpoints:
                 json={
                     "device_id": device.device_id,
                     "spool_id": spool.id,
-                    "tag_uid": "04AABBCC",
+                    "tag_uid": "04AABB",
                     "success": False,
                     "message": "Write or verification failed",
                 },
@@ -609,7 +607,7 @@ class TestWriteTagEndpoints:
                 json={
                     "device_id": device.device_id,
                     "spool_id": spool.id,
-                    "tag_uid": "AABBCCDD",
+                    "tag_uid": "AABB",
                     "success": True,
                 },
             )
@@ -1078,46 +1076,6 @@ class TestUpdateEndpoints:
         )
         assert resp.status_code == 404
 
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_report_update_status_invalid_status_422(self, async_client: AsyncClient, device_factory):
-        """Arbitrary status strings must be rejected with 422 (H2: UpdateStatusRequest validation)."""
-        await device_factory(device_id="sb-upd-inv")
-        resp = await async_client.post(
-            f"{API}/devices/sb-upd-inv/update-status",
-            json={"status": "hacked", "message": "injected"},
-        )
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_report_update_status_oversized_message_422(self, async_client: AsyncClient, device_factory):
-        """Message exceeding 255 chars must be rejected with 422 (H2/M4)."""
-        await device_factory(device_id="sb-upd-big")
-        resp = await async_client.post(
-            f"{API}/devices/sb-upd-big/update-status",
-            json={"status": "updating", "message": "x" * 256},
-        )
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_ssh_public_key_error_does_not_leak_exception_text(self, async_client: AsyncClient):
-        """SSH public-key 500 must not expose raw exception details (M3)."""
-        from backend.app.services.spoolbuddy_ssh import get_public_key
-
-        with patch(
-            "backend.app.services.spoolbuddy_ssh.get_public_key",
-            AsyncMock(side_effect=RuntimeError("REDACT_ME internal path /data/keys/id_ed25519")),
-        ):
-            resp = await async_client.get(f"{API}/ssh/public-key")
-
-        assert resp.status_code == 500
-        body = resp.json()["detail"]
-        assert "REDACT_ME" not in body
-        assert "/data/keys" not in body
-        assert "id_ed25519" not in body
-
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
@@ -1305,1137 +1263,3 @@ class TestSystemCommandEndpoints:
         assert resp.status_code == 200
         data = resp.json()
         assert data["pending_command"] == "restart_browser"
-
-
-# ============================================================================
-# Spoolman-aware SpoolBuddy endpoints
-# ============================================================================
-
-
-@pytest.fixture
-async def spoolman_settings(db_session: AsyncSession):
-    """Create Spoolman settings in the database (enabled with URL)."""
-    from backend.app.models.settings import Settings
-
-    settings = [
-        Settings(key="spoolman_enabled", value="true"),
-        Settings(key="spoolman_url", value="http://spoolman.local:7912"),
-    ]
-    for s in settings:
-        db_session.add(s)
-    await db_session.commit()
-    return settings
-
-
-def _mock_spoolman_client(base_url: str = "http://spoolman.local:7912") -> MagicMock:
-    client = MagicMock()
-    client.base_url = base_url
-    client.get_spools = AsyncMock(return_value=[])
-    client.get_spool = AsyncMock(return_value={})
-    client.find_spool_by_tag = AsyncMock(return_value=None)
-    client.update_spool = AsyncMock(return_value=None)
-    client.merge_spool_extra = AsyncMock(return_value={"id": 0})
-    return client
-
-
-def _spoolman_spool_fixture(
-    spool_id: int,
-    spool_weight: float = 196.0,
-    filament_weight: float = 1000.0,
-    spool_level_spool_weight=None,
-) -> dict:
-    """Build a minimal Spoolman spool dict with realistic core weight from filament.spool_weight."""
-    raw = {
-        "id": spool_id,
-        "filament": {"weight": filament_weight, "spool_weight": spool_weight},
-        "used_weight": 0.0,
-    }
-    if spool_level_spool_weight is not None:
-        raw["spool_weight"] = spool_level_spool_weight
-    return raw
-
-
-class TestUpdateSpoolWeightSpoolman:
-    """update-spool-weight routes to Spoolman when Spoolman mode is active."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_mode_uses_filament_spool_weight(self, async_client: AsyncClient, spoolman_settings):
-        """core_weight comes from filament.spool_weight, not a hardcoded constant."""
-        sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0)
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sm_spool)
-        mock_client.update_spool = AsyncMock(return_value=sm_spool)
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/scale/update-spool-weight",
-                json={"spool_id": 42, "weight_grams": 750},
-            )
-
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["status"] == "ok"
-        # remaining = max(0, 750 - 196) = 554 → weight_used = 1000 - 554 = 446
-        assert data["weight_used"] == pytest.approx(446.0)
-        mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(554.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_mode_clamps_remaining_to_zero(self, async_client: AsyncClient, spoolman_settings):
-        """Scale weight below core weight → remaining_weight = 0."""
-        sm_spool = _spoolman_spool_fixture(7, spool_weight=196.0, filament_weight=1000.0)
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sm_spool)
-        mock_client.update_spool = AsyncMock(return_value=sm_spool)
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/scale/update-spool-weight",
-                json={"spool_id": 7, "weight_grams": 100},
-            )
-
-        assert resp.status_code == 200
-        mock_client.update_spool.assert_called_once_with(spool_id=7, remaining_weight=0.0)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_mode_404_when_spool_not_found(self, async_client: AsyncClient, spoolman_settings):
-        """404 when Spoolman doesn't know the spool."""
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/scale/update-spool-weight",
-                json={"spool_id": 9999, "weight_grams": 500},
-            )
-
-        assert resp.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_mode_503_on_client_failure(self, async_client: AsyncClient, spoolman_settings):
-        """503 is returned when Spoolman is unreachable during weight update."""
-        sm_spool = _spoolman_spool_fixture(99)
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sm_spool)
-        mock_client.update_spool = AsyncMock(side_effect=SpoolmanUnavailableError("Spoolman down"))
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/scale/update-spool-weight",
-                json={"spool_id": 99, "weight_grams": 500},
-            )
-
-        assert resp.status_code == 503
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_local_mode_unchanged(self, async_client: AsyncClient, spool_factory):
-        """When Spoolman is NOT enabled, local DB update still works."""
-        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
-
-        resp = await async_client.post(
-            f"{API}/scale/update-spool-weight",
-            json={"spool_id": spool.id, "weight_grams": 750},
-        )
-
-        assert resp.status_code == 200
-        assert resp.json()["weight_used"] == 500
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
-        """spool.spool_weight overrides filament.spool_weight for tare calculation."""
-        sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0, spool_level_spool_weight=300)
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sm_spool)
-        mock_client.update_spool = AsyncMock(return_value=sm_spool)
-
-        with (
-            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
-            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
-        ):
-            resp = await async_client.post(
-                f"{API}/scale/update-spool-weight",
-                json={"spool_id": 42, "weight_grams": 750},
-            )
-
-        assert resp.status_code == 200
-        # remaining = 750 - 300 = 450; weight_used = 1000 - 450 = 550
-        assert resp.json()["weight_used"] == pytest.approx(550.0)
-        mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(450.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_level_zero_spool_weight_not_treated_as_missing(
-        self, async_client: AsyncClient, spoolman_settings
-    ):
-        """spool.spool_weight=0 is valid (0g tare), not treated as missing/fallback."""
-        sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0, spool_level_spool_weight=0)
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sm_spool)
-        mock_client.update_spool = AsyncMock(return_value=sm_spool)
-
-        with (
-            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
-            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
-        ):
-            resp = await async_client.post(
-                f"{API}/scale/update-spool-weight",
-                json={"spool_id": 42, "weight_grams": 750},
-            )
-
-        assert resp.status_code == 200
-        # remaining = 750 - 0 = 750; weight_used = 1000 - 750 = 250
-        assert resp.json()["weight_used"] == pytest.approx(250.0)
-        mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(750.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_both_levels_none_uses_250g_fallback_and_warns(self, async_client: AsyncClient, spoolman_settings):
-        """When both spool_weight and filament.spool_weight are None, 250g fallback is used with a warning."""
-        sm_spool = {"id": 42, "filament": {"weight": 1000.0, "spool_weight": None}, "used_weight": 0.0}
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sm_spool)
-        mock_client.update_spool = AsyncMock(return_value=sm_spool)
-
-        with (
-            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
-            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
-        ):
-            resp = await async_client.post(
-                f"{API}/scale/update-spool-weight",
-                json={"spool_id": 42, "weight_grams": 750},
-            )
-
-        assert resp.status_code == 200
-        # remaining = 750 - 250 = 500; weight_used = 1000 - 500 = 500
-        assert resp.json()["weight_used"] == pytest.approx(500.0)
-        assert resp.json().get("warnings")
-
-
-class TestTagScannedSpoolmanFallback:
-    """nfc/tag-scanned falls back to Spoolman when local DB has no match."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_fallback_on_local_miss(self, async_client: AsyncClient, spoolman_settings):
-        raw_spool = {
-            "id": 5,
-            "filament": {
-                "material": "PETG",
-                "name": "PETG Basic",
-                "color_hex": "00FF00",
-                "weight": 1000,
-                "vendor": {"name": "Polymaker"},
-            },
-            "used_weight": 100.0,
-            "archived": False,
-            "registered": "2024-01-01T00:00:00+00:00",
-            "extra": {"tag": '"DEADBEEF12345678"'},
-        }
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spools = AsyncMock(return_value=[raw_spool])
-        mock_client.find_spool_by_tag = AsyncMock(return_value=raw_spool)
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
-                new_callable=AsyncMock,
-                return_value=None,
-            ),
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/tag-scanned",
-                json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"},
-            )
-
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["matched"] is True
-        assert data["spool_id"] == 5
-        mock_ws.broadcast.assert_called_once()
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_tag_matched"
-        assert msg["spool"]["id"] == 5
-        assert msg["spool"]["material"] == "PETG"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_fallback_unknown_when_no_spoolman_match(self, async_client: AsyncClient, spoolman_settings):
-        """Unknown tag broadcast when both local DB and Spoolman miss."""
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spools = AsyncMock(return_value=[])
-        mock_client.find_spool_by_tag = AsyncMock(return_value=None)
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
-                new_callable=AsyncMock,
-                return_value=None,
-            ),
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/tag-scanned",
-                json={"device_id": "sb-1", "tag_uid": "UNKNOWN0000000FF"},
-            )
-
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["matched"] is False
-        assert data["spool_id"] is None
-        mock_ws.broadcast.assert_called_once()
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_unknown_tag"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_malformed_spoolman_data_degrades_gracefully(self, async_client: AsyncClient, spoolman_settings):
-        """ValueError from _map_spoolman_spool (e.g. spool_id=0) must return matched=False without broadcasting unknown_tag."""
-        bad_spool = {
-            "id": 0,  # _map_spoolman_spool raises ValueError for id <= 0
-            "filament": {"material": "PLA", "name": "PLA Basic", "color_hex": "FF0000", "weight": 1000},
-            "used_weight": 0.0,
-            "archived": False,
-            "registered": "2024-01-01T00:00:00Z",
-            "extra": {"tag": '"DEADBEEF12345678"'},
-        }
-        mock_client = _mock_spoolman_client()
-        mock_client.find_spool_by_tag = AsyncMock(return_value=bad_spool)
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
-                new_callable=AsyncMock,
-                return_value=None,
-            ),
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/tag-scanned",
-                json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"},
-            )
-
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["matched"] is False
-        assert data["spool_id"] is None
-        # No broadcast: UI must not get a spurious unknown_tag event on Spoolman data errors
-        mock_ws.broadcast.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_local_match_skips_spoolman(self, async_client: AsyncClient, spool_factory):
-        """When local DB matches, Spoolman is never queried."""
-        spool = await spool_factory(tag_uid="AABB1122", material="PLA")
-        mock_spool = MagicMock()
-        mock_spool.id = spool.id
-        mock_spool.material = spool.material
-        mock_spool.subtype = spool.subtype
-        mock_spool.color_name = spool.color_name
-        mock_spool.rgba = spool.rgba
-        mock_spool.brand = spool.brand
-        mock_spool.label_weight = spool.label_weight
-        mock_spool.core_weight = spool.core_weight
-        mock_spool.weight_used = spool.weight_used
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
-                new_callable=AsyncMock,
-                return_value=mock_spool,
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/tag-scanned",
-                json={"device_id": "sb-1", "tag_uid": "AABB1122"},
-            )
-
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["matched"] is True
-        assert data["spool_id"] == spool.id
-
-
-# ============================================================================
-# NFC write-tag / write-result — Spoolman-aware
-# ============================================================================
-
-
-def _full_spoolman_spool(spool_id: int) -> dict:
-    """Complete Spoolman spool dict sufficient for NDEF encoding."""
-    return {
-        "id": spool_id,
-        "filament": {
-            "material": "PLA",
-            "name": "PLA Basic",
-            "color_hex": "FF0000",
-            "weight": 1000.0,
-            "spool_weight": 196.0,
-            "vendor": {"name": "Bambu Lab"},
-        },
-        "used_weight": 0.0,
-        "archived": False,
-        "registered": "2024-01-01T00:00:00Z",
-    }
-
-
-class TestNfcWriteTagSpoolman:
-    """nfc/write-tag falls back to Spoolman when local DB has no matching spool."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_spool_queued_when_local_miss(
-        self, async_client: AsyncClient, device_factory, spoolman_settings
-    ):
-        """write-tag encodes NDEF from Spoolman data when spool not in local DB."""
-        await device_factory(device_id="sb-write-sm")
-        sm_spool = _full_spoolman_spool(77)
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sm_spool)
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/nfc/write-tag",
-                json={"device_id": "sb-write-sm", "spool_id": 77},
-            )
-
-        assert resp.status_code == 200
-        assert resp.json()["status"] == "queued"
-        mock_client.get_spool.assert_called_once_with(77)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_data_origin_spoolman_stored_in_payload(
-        self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
-    ):
-        """Pending write payload records data_origin=spoolman for Spoolman spools."""
-        import json as _json
-
-        device = await device_factory(device_id="sb-origin")
-        sm_spool = _full_spoolman_spool(88)
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sm_spool)
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            await async_client.post(
-                f"{API}/nfc/write-tag",
-                json={"device_id": "sb-origin", "spool_id": 88},
-            )
-
-        await db_session.refresh(device)
-        payload = _json.loads(device.pending_write_payload)
-        assert payload["data_origin"] == "spoolman"
-        assert payload["spool_id"] == 88
-        assert "ndef_data_hex" in payload
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_404_when_neither_local_nor_spoolman(
-        self, async_client: AsyncClient, device_factory, spoolman_settings
-    ):
-        """404 returned when spool is missing from both local DB and Spoolman."""
-        await device_factory(device_id="sb-miss")
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/nfc/write-tag",
-                json={"device_id": "sb-miss", "spool_id": 9999},
-            )
-
-        assert resp.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_local_spool_used_when_present(self, async_client: AsyncClient, device_factory, spool_factory):
-        """Local DB spool is encoded directly without contacting Spoolman."""
-        await device_factory(device_id="sb-local-write")
-        spool = await spool_factory(material="PETG")
-
-        resp = await async_client.post(
-            f"{API}/nfc/write-tag",
-            json={"device_id": "sb-local-write", "spool_id": spool.id},
-        )
-
-        assert resp.status_code == 200
-        assert resp.json()["status"] == "queued"
-
-
-class TestNfcWriteResultSpoolman:
-    """nfc/write-result updates Spoolman extra.tag on success for Spoolman spools."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_success_updates_spoolman_extra_tag(
-        self, async_client: AsyncClient, device_factory, spoolman_settings
-    ):
-        """Successful write for a Spoolman spool calls merge_spool_extra with extra.tag."""
-        import json as _json
-
-        await device_factory(
-            device_id="sb-wr-sm",
-            pending_command="write_tag",
-            pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
-        )
-        mock_client = _mock_spoolman_client()
-        mock_client.merge_spool_extra = AsyncMock(return_value={"id": 55})
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/write-result",
-                json={
-                    "device_id": "sb-wr-sm",
-                    "spool_id": 55,
-                    "tag_uid": "AABBCCDD11223344",
-                    "success": True,
-                },
-            )
-
-        assert resp.status_code == 200
-        mock_client.merge_spool_extra.assert_called_once_with(55, {"tag": '"AABBCCDD11223344"'})
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_tag_written"
-        assert msg["tag_uid"] == "AABBCCDD11223344"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_failure_does_not_call_spoolman(self, async_client: AsyncClient, device_factory, spoolman_settings):
-        """Failed write never calls Spoolman update."""
-        import json as _json
-
-        await device_factory(
-            device_id="sb-wr-fail",
-            pending_command="write_tag",
-            pending_write_payload=_json.dumps({"spool_id": 66, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
-        )
-        mock_client = _mock_spoolman_client()
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/write-result",
-                json={
-                    "device_id": "sb-wr-fail",
-                    "spool_id": 66,
-                    "tag_uid": "AABBCCDD11223344",
-                    "success": False,
-                    "message": "write timeout",
-                },
-            )
-
-        assert resp.status_code == 200
-        mock_client.update_spool.assert_not_called()
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_tag_write_failed"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_success_local_spool_writes_to_db(
-        self, async_client: AsyncClient, device_factory, spool_factory, db_session
-    ):
-        """Successful write for a local spool still updates local DB tag_uid."""
-        import json as _json
-
-        spool = await spool_factory()
-        await device_factory(
-            device_id="sb-wr-local",
-            pending_command="write_tag",
-            pending_write_payload=_json.dumps(
-                {"spool_id": spool.id, "ndef_data_hex": "deadbeef", "data_origin": "local"}
-            ),
-        )
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/write-result",
-                json={
-                    "device_id": "sb-wr-local",
-                    "spool_id": spool.id,
-                    "tag_uid": "DEADBEEF12345678",
-                    "success": True,
-                },
-            )
-
-        assert resp.status_code == 200
-        await db_session.refresh(spool)
-        assert spool.tag_uid == "DEADBEEF12345678"
-        assert spool.tag_type == "ntag"
-
-
-# ============================================================================
-# Security fix tests — write-tag ValueError + write-result exception safety
-# ============================================================================
-
-
-class TestNfcWriteTagSpoolmanSecurityFixes:
-    """Regression tests for security fixes in nfc/write-tag Spoolman path."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_invalid_spoolman_spool_id_returns_502(
-        self, async_client: AsyncClient, device_factory, spoolman_settings
-    ):
-        """Malformed Spoolman spool (invalid id=0) raises 502, not 404 — spool exists but is bad data."""
-        await device_factory(device_id="sb-invalid-id")
-        # Spoolman returns spool with id=0 (invalid — caught by _map_spoolman_spool guard)
-        bad_spool = {**_full_spoolman_spool(1), "id": 0}
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=bad_spool)
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/nfc/write-tag",
-                json={"device_id": "sb-invalid-id", "spool_id": 99},
-            )
-
-        # 502: spool exists in Spoolman but its data is malformed — not a "not found"
-        assert resp.status_code == 502
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_oversized_label_weight_does_not_crash(
-        self, async_client: AsyncClient, device_factory, spoolman_settings
-    ):
-        """label_weight > 65535 from Spoolman must not crash with struct.error."""
-        await device_factory(device_id="sb-overflow")
-        big_weight_spool = {
-            **_full_spoolman_spool(42),
-            "filament": {**_full_spoolman_spool(42)["filament"], "weight": 70000},
-        }
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=big_weight_spool)
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/nfc/write-tag",
-                json={"device_id": "sb-overflow", "spool_id": 42},
-            )
-
-        assert resp.status_code == 200
-        assert resp.json()["status"] == "queued"
-
-
-class TestNfcWriteResultSpoolmanSecurityFixes:
-    """Regression tests for transaction safety in nfc/write-result Spoolman path."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_client_exception_still_clears_device_state(
-        self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
-    ):
-        """If Spoolman client raises, device pending_command is still cleared in DB."""
-        import json as _json
-
-        device = await device_factory(
-            device_id="sb-exc-safe",
-            pending_command="write_tag",
-            pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
-        )
-        mock_client = _mock_spoolman_client()
-        mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused"))
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/write-result",
-                json={
-                    "device_id": "sb-exc-safe",
-                    "spool_id": 77,
-                    "tag_uid": "AABBCCDD11223344",
-                    "success": True,
-                },
-            )
-
-        # 502: tag written to NFC but Spoolman link failed (not best-effort — caller must retry)
-        assert resp.status_code == 502
-        # Device state must be cleared despite the exception (no spurious re-write)
-        await db_session.refresh(device)
-        assert device.pending_command is None
-        assert device.pending_write_payload is None
-        # Failure broadcast fires so the UI can show the error
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_tag_link_failed"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_not_found_error_broadcasts_link_failed(
-        self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
-    ):
-        """SpoolmanNotFoundError from merge_spool_extra must clear device state and broadcast link_failed."""
-        import json as _json
-
-        device = await device_factory(
-            device_id="sb-notfound",
-            pending_command="write_tag",
-            pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
-        )
-        mock_client = _mock_spoolman_client()
-        mock_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 55 not found"))
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/write-result",
-                json={
-                    "device_id": "sb-notfound",
-                    "spool_id": 55,
-                    "tag_uid": "AABBCCDD11223344",
-                    "success": True,
-                },
-            )
-
-        assert resp.status_code == 502
-        await db_session.refresh(device)
-        assert device.pending_command is None
-        assert device.pending_write_payload is None
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_tag_link_failed"
-        assert msg["spool_id"] == 55
-
-
-class TestNfcWriteResultOrphanedSpool:
-    """nfc/write-result when the local spool was deleted between write-queue and write-result."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_local_spool_deleted_before_write_back(self, async_client: AsyncClient, device_factory, db_session):
-        """When local spool is deleted between write-queue and write-result, return linked=False and broadcast link_failed."""
-        import json as _json
-
-        device = await device_factory(
-            device_id="sb-orphan",
-            pending_command="write_tag",
-            pending_write_payload=_json.dumps(
-                {
-                    "spool_id": 99999,  # non-existent spool
-                    "ndef_data_hex": "aabbccdd",
-                    "data_origin": "local",
-                }
-            ),
-        )
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/write-result",
-                json={"device_id": device.device_id, "spool_id": 99999, "success": True, "tag_uid": "AABBCCDD"},
-            )
-
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["linked"] is False
-
-        # pending command should be cleared
-        await db_session.refresh(device)
-        assert device.pending_command is None
-
-        # broadcast should be spoolbuddy_tag_link_failed
-        broadcast_calls = mock_ws.broadcast.call_args_list
-        link_failed = [c[0][0] for c in broadcast_calls if c[0][0].get("type") == "spoolbuddy_tag_link_failed"]
-        assert len(link_failed) >= 1
-
-
-class TestNfcWriteResultInputValidation:
-    """Input validation and JSON safety for nfc/write-result."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_uid_too_long_rejected(self, async_client: AsyncClient, device_factory):
-        """tag_uid longer than 32 chars must be rejected with 422."""
-        import json as _json
-
-        await device_factory(
-            device_id="sb-uid-long",
-            pending_command="write_tag",
-            pending_write_payload=_json.dumps({"spool_id": 1, "ndef_data_hex": "dead", "data_origin": "local"}),
-        )
-
-        resp = await async_client.post(
-            f"{API}/nfc/write-result",
-            json={
-                "device_id": "sb-uid-long",
-                "spool_id": 1,
-                "tag_uid": "A" * 65,
-                "success": True,
-            },
-        )
-
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_malformed_pending_payload_falls_back_to_local(
-        self, async_client: AsyncClient, device_factory, spool_factory, db_session
-    ):
-        """Corrupted pending_write_payload JSON falls back to local mode gracefully."""
-        spool = await spool_factory()
-        await device_factory(
-            device_id="sb-corrupt-json",
-            pending_command="write_tag",
-            pending_write_payload="{not valid json!!!",
-        )
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/write-result",
-                json={
-                    "device_id": "sb-corrupt-json",
-                    "spool_id": spool.id,
-                    "tag_uid": "DEADBEEF12345678",
-                    "success": True,
-                },
-            )
-
-        # Must return 200, not 500
-        assert resp.status_code == 200
-        # Falls back to local mode — tag written to DB
-        await db_session.refresh(spool)
-        assert spool.tag_uid == "DEADBEEF12345678"
-
-
-# ============================================================================
-# B1: NFC write-tag warnings appear in response body
-# ============================================================================
-
-
-class TestNfcWriteTagWarningsBody:
-    """B1: resp.json()['warnings'] is populated when Spoolman fields are absent."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_warnings_returned_for_missing_color_and_temp(
-        self, async_client: AsyncClient, device_factory, spoolman_settings
-    ):
-        """Both color_name=None and settings_extruder_temp=None produce 2 warnings."""
-        await device_factory(device_id="sb-warn-b1")
-        # Spoolman spool with no color_name or nozzle temp
-        sparse_spool = {
-            "id": 99,
-            "filament": {
-                "material": "PLA",
-                "name": "PLA Basic",
-                "color_hex": "808080",
-                # color_name absent → None after mapping
-                # settings_extruder_temp absent → nozzle_temp_min=None
-                "weight": 1000.0,
-                "vendor": {"name": "Bambu Lab"},
-            },
-            "used_weight": 0.0,
-            "archived": False,
-            "registered": "2024-01-01T00:00:00Z",
-        }
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=sparse_spool)
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/nfc/write-tag",
-                json={"device_id": "sb-warn-b1", "spool_id": 99},
-            )
-
-        assert resp.status_code == 200
-        body = resp.json()
-        assert "warnings" in body, "Response should contain 'warnings' key when fields are absent"
-        warnings = body["warnings"]
-        assert len(warnings) >= 2, f"Expected at least 2 warnings for missing color_name + nozzle_temp, got: {warnings}"
-        # Confirm the specific fields are mentioned
-        warn_text = " ".join(warnings)
-        assert "color_name" in warn_text
-        assert "nozzle_temp" in warn_text
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_warnings_key_when_all_fields_present(
-        self, async_client: AsyncClient, device_factory, spoolman_settings
-    ):
-        """No 'warnings' key in response when all fields are populated."""
-        await device_factory(device_id="sb-nowarn")
-        full_spool = _full_spoolman_spool(100)
-        # Add color_name and extruder temp
-        full_spool["filament"]["color_name"] = "Red"
-        full_spool["filament"]["settings_extruder_temp"] = 220
-        mock_client = _mock_spoolman_client()
-        mock_client.get_spool = AsyncMock(return_value=full_spool)
-
-        with (
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            resp = await async_client.post(
-                f"{API}/nfc/write-tag",
-                json={"device_id": "sb-nowarn", "spool_id": 100},
-            )
-
-        assert resp.status_code == 200
-        body = resp.json()
-        assert "warnings" not in body or body["warnings"] == []
-
-
-# ============================================================================
-# B5: Exception text scrubbed from WebSocket broadcast message
-# ============================================================================
-
-
-class TestNfcWriteResultExceptionScrubbing:
-    """B5: Internal exception details must not appear in WebSocket 'message' field."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_exception_text_not_leaked_in_ws_message(
-        self, async_client: AsyncClient, device_factory, db_session, spoolman_settings
-    ):
-        """When Spoolman merge raises, WS message is generic; 'connection refused' absent."""
-        import json as _json
-
-        await device_factory(
-            device_id="sb-scrub-b5",
-            pending_command="write_tag",
-            pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}),
-        )
-        mock_client = _mock_spoolman_client()
-        mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused to 192.168.1.1:7912"))
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{API}/nfc/write-result",
-                json={
-                    "device_id": "sb-scrub-b5",
-                    "spool_id": 77,
-                    "tag_uid": "AABBCCDD11223344",
-                    "success": True,
-                },
-            )
-
-        assert resp.status_code == 502
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_tag_link_failed"
-        # Generic message — no internal exception details leaked
-        assert msg["message"] == "Spoolman link failed", f"Expected generic message but got: {msg['message']!r}"
-        assert "connection refused" not in str(msg), f"Exception text must not appear in WS message: {msg}"
-        assert "192.168.1" not in str(msg), f"Internal IP must not appear in WS message: {msg}"
-
-
-# ============================================================================
-# _get_spoolman_client_or_none: graceful degradation on ValueError during reinit
-# ============================================================================
-
-
-class TestSpoolmanClientOrNoneGraceful:
-    """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_returns_none_when_init_raises_value_error(self, async_client: AsyncClient, db_session):
-        """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError,
-        so the device endpoint degrades gracefully instead of propagating a 500 error."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
-        await db_session.commit()
-
-        with (
-            patch("backend.app.api.routes._spoolman_helpers.assert_safe_spoolman_url"),
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=None),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(side_effect=ValueError("invalid URL")),
-            ),
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-        ):
-            mock_ws.broadcast = AsyncMock()
-            # nfc/tag-scanned calls _get_spoolman_client_or_none; with None returned it
-            # must broadcast unknown_tag (not raise 500 due to ValueError propagating).
-            resp = await async_client.post(
-                f"{API}/nfc/tag-scanned",
-                json={"device_id": "sb-vale", "tag_uid": "AABBCCDD"},
-            )
-
-        # Must not be 500 — ValueError is caught and client returns None, degrading gracefully
-        assert resp.status_code == 200
-        data = resp.json()
-        assert data["matched"] is False

+ 0 - 387
backend/tests/integration/test_spoolbuddy_spoolman_nfc.py

@@ -1,387 +0,0 @@
-"""Integration tests for SpoolBuddy + Spoolman NFC fixes.
-
-Group 1 – tag-scanned broadcasts include tray_uuid in all WebSocket messages.
-Group 2 – PATCH /api/v1/spoolman/inventory/spools/{id}/tag endpoint.
-"""
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from httpx import AsyncClient
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from backend.app.models.settings import Settings
-from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
-
-SPOOLBUDDY_API = "/api/v1/spoolbuddy"
-INVENTORY_API = "/api/v1/spoolman/inventory"
-
-
-# ---------------------------------------------------------------------------
-# Shared helpers
-# ---------------------------------------------------------------------------
-
-
-@pytest.fixture
-async def spoolman_settings_local(db_session: AsyncSession):
-    """Spoolman enabled, URL = spoolman.local (matches SpoolBuddy service patches)."""
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
-    await db_session.commit()
-
-
-@pytest.fixture
-async def spoolman_settings_inventory(db_session: AsyncSession):
-    """Spoolman enabled, URL = localhost (matches inventory proxy patches)."""
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-    await db_session.commit()
-
-
-def _spoolman_spool(spool_id: int) -> dict:
-    """Minimal Spoolman raw spool dict suitable for _map_spoolman_spool()."""
-    return {
-        "id": spool_id,
-        "filament": {
-            "material": "PLA",
-            "name": "PLA Basic",
-            "color_hex": "FF0000",
-            "weight": 1000.0,
-            "spool_weight": 196.0,
-            "vendor": {"name": "Bambu Lab"},
-        },
-        "used_weight": 0.0,
-        "archived": False,
-        "registered": "2024-01-01T00:00:00Z",
-    }
-
-
-def _mock_spoolman_client_local() -> MagicMock:
-    client = MagicMock()
-    client.base_url = "http://spoolman.local:7912"
-    client.get_spools = AsyncMock(return_value=[])
-    client.find_spool_by_tag = AsyncMock(return_value=None)
-    client.merge_spool_extra = AsyncMock(return_value={})
-    return client
-
-
-# ---------------------------------------------------------------------------
-# Group 1: broadcast tests — tray_uuid forwarded in all WS broadcasts
-# ---------------------------------------------------------------------------
-
-
-class TestTagScannedBroadcastsTrayUuid:
-    """nfc/tag-scanned broadcasts include tray_uuid from the request payload."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_local_match_broadcast_includes_tray_uuid(self, async_client: AsyncClient, spoolman_settings_local):
-        """Local DB match broadcasts tray_uuid alongside tag_uid."""
-        mock_local_spool = MagicMock()
-        mock_local_spool.id = 1
-        mock_local_spool.material = "PLA"
-        mock_local_spool.subtype = None
-        mock_local_spool.color_name = "Red"
-        mock_local_spool.rgba = "FF0000FF"
-        mock_local_spool.brand = "Bambu Lab"
-        mock_local_spool.label_weight = 1000
-        mock_local_spool.core_weight = 250
-        mock_local_spool.weight_used = 0
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
-                new_callable=AsyncMock,
-                return_value=mock_local_spool,
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{SPOOLBUDDY_API}/nfc/tag-scanned",
-                json={
-                    "device_id": "sb-test",
-                    "tag_uid": "AABB1122334455FF",
-                    "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
-                },
-            )
-
-        assert resp.status_code == 200
-        assert resp.json()["matched"] is True
-        mock_ws.broadcast.assert_called_once()
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_tag_matched"
-        assert msg["tag_uid"] == "AABB1122334455FF"
-        assert msg["tray_uuid"] == "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_match_broadcast_includes_tray_uuid(
-        self, async_client: AsyncClient, spoolman_settings_local
-    ):
-        """Spoolman fallback match broadcasts tray_uuid alongside tag_uid."""
-        sm_spool = _spoolman_spool(5)
-        sm_spool["extra"] = {"tag": '"DEADBEEFDEADBEEFDEADBEEFDEADBEEF"'}
-        mock_client = _mock_spoolman_client_local()
-        mock_client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
-                new_callable=AsyncMock,
-                return_value=None,
-            ),
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{SPOOLBUDDY_API}/nfc/tag-scanned",
-                json={
-                    "device_id": "sb-test",
-                    "tag_uid": "AABB1122334455FF",
-                    "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
-                },
-            )
-
-        assert resp.status_code == 200
-        assert resp.json()["matched"] is True
-        mock_ws.broadcast.assert_called_once()
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_tag_matched"
-        assert msg["tray_uuid"] == "DEADBEEFDEADBEEFDEADBEEFDEADBEEF"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unknown_tag_broadcast_includes_tray_uuid(self, async_client: AsyncClient, spoolman_settings_local):
-        """Unknown tag broadcast includes tray_uuid when Bambu spool is not yet linked."""
-        mock_client = _mock_spoolman_client_local()
-        mock_client.find_spool_by_tag = AsyncMock(return_value=None)
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
-                new_callable=AsyncMock,
-                return_value=None,
-            ),
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{SPOOLBUDDY_API}/nfc/tag-scanned",
-                json={
-                    "device_id": "sb-test",
-                    "tag_uid": "AABB1122334455FF",
-                    "tray_uuid": "CAFEBABECAFEBABECAFEBABECAFEBABE",
-                },
-            )
-
-        assert resp.status_code == 200
-        assert resp.json()["matched"] is False
-        mock_ws.broadcast.assert_called_once()
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_unknown_tag"
-        assert msg["tray_uuid"] == "CAFEBABECAFEBABECAFEBABECAFEBABE"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unknown_tag_broadcast_tray_uuid_null_when_absent(
-        self, async_client: AsyncClient, spoolman_settings_local
-    ):
-        """tray_uuid is None in the broadcast when the request omits it."""
-        mock_client = _mock_spoolman_client_local()
-        mock_client.find_spool_by_tag = AsyncMock(return_value=None)
-
-        with (
-            patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws,
-            patch(
-                "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
-                new_callable=AsyncMock,
-                return_value=None,
-            ),
-            patch(
-                "backend.app.services.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.services.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                f"{SPOOLBUDDY_API}/nfc/tag-scanned",
-                json={"device_id": "sb-test", "tag_uid": "AABB1122334455FF"},
-            )
-
-        assert resp.status_code == 200
-        assert resp.json()["matched"] is False
-        mock_ws.broadcast.assert_called_once()
-        msg = mock_ws.broadcast.call_args[0][0]
-        assert msg["type"] == "spoolbuddy_unknown_tag"
-        assert msg["tray_uuid"] is None
-
-
-# ---------------------------------------------------------------------------
-# Group 2: PATCH /spoolman/inventory/spools/{id}/tag endpoint
-# ---------------------------------------------------------------------------
-
-
-class TestLinkTagToSpoolmanSpool:
-    """PATCH /spoolman/inventory/spools/{id}/tag writes an NFC tag into Spoolman extra.tag."""
-
-    def _mock_client(self, spool_id: int) -> MagicMock:
-        client = MagicMock()
-        client.base_url = "http://localhost:7912"
-        # get_all_spools returns empty list — no duplicate tags in Spoolman.
-        client.get_all_spools = AsyncMock(return_value=[])
-        client.get_spool = AsyncMock(return_value=_spoolman_spool(spool_id))
-        client.update_spool_full = AsyncMock(return_value=_spoolman_spool(spool_id))
-        return client
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_tag_uid_writes_to_extra_tag(self, async_client: AsyncClient):
-        """PATCH with tag_uid writes uppercased tag_uid to Spoolman extra.tag."""
-        import json as _json
-
-        mock_client = self._mock_client(42)
-
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.patch(
-                f"{INVENTORY_API}/spools/42/tag",
-                json={"tag_uid": "aabb1122334455ff"},
-            )
-
-        assert resp.status_code == 200
-        mock_client.update_spool_full.assert_called_once()
-        _, kwargs = mock_client.update_spool_full.call_args
-        assert kwargs.get("extra", {}).get("tag") == _json.dumps("AABB1122334455FF")
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tray_uuid_takes_precedence_over_tag_uid(self, async_client: AsyncClient):
-        """tray_uuid takes precedence when both tag_uid and tray_uuid are provided."""
-        import json as _json
-
-        mock_client = self._mock_client(7)
-
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.patch(
-                f"{INVENTORY_API}/spools/7/tag",
-                json={
-                    "tag_uid": "AABB1122334455FF",
-                    "tray_uuid": "deadbeefdeadbeefdeadbeefdeadbeef",
-                },
-            )
-
-        assert resp.status_code == 200
-        mock_client.update_spool_full.assert_called_once()
-        _, kwargs = mock_client.update_spool_full.call_args
-        assert kwargs.get("extra", {}).get("tag") == _json.dumps("DEADBEEFDEADBEEFDEADBEEFDEADBEEF")
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_neither_tag_uid_nor_tray_uuid_returns_422(self, async_client: AsyncClient):
-        """422 Unprocessable Entity when neither tag_uid nor tray_uuid is provided."""
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=MagicMock()),
-        ):
-            resp = await async_client.patch(
-                f"{INVENTORY_API}/spools/1/tag",
-                json={},
-            )
-
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_not_found_returns_404(self, async_client: AsyncClient):
-        """404 when Spoolman reports the spool does not exist."""
-        mock_client = MagicMock()
-        mock_client.get_all_spools = AsyncMock(return_value=[])
-        mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 999 not found"))
-
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.patch(
-                f"{INVENTORY_API}/spools/999/tag",
-                json={"tag_uid": "AABB1122334455FF"},
-            )
-
-        assert resp.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_unavailable_returns_503(self, async_client: AsyncClient):
-        """503 when Spoolman is unreachable during the tag link (duplicate check fails first)."""
-        mock_client = MagicMock()
-        mock_client.get_all_spools = AsyncMock(side_effect=SpoolmanUnavailableError("Spoolman down"))
-
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.patch(
-                f"{INVENTORY_API}/spools/42/tag",
-                json={"tag_uid": "AABB1122334455FF"},
-            )
-
-        assert resp.status_code == 503
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_returns_400_when_spoolman_disabled(self, async_client: AsyncClient):
-        """400 when Spoolman integration is not enabled (no settings in DB)."""
-        resp = await async_client.patch(
-            f"{INVENTORY_API}/spools/42/tag",
-            json={"tag_uid": "AABB1122334455FF"},
-        )
-
-        assert resp.status_code == 400
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_4_byte_uid_writes_to_extra_tag(self, async_client: AsyncClient):
-        """PATCH with 8-char (4-byte Bambu Lab) tag_uid writes correctly to Spoolman extra.tag."""
-        import json as _json
-
-        mock_client = self._mock_client(42)
-
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.patch(
-                f"{INVENTORY_API}/spools/42/tag",
-                json={"tag_uid": "2728C17B"},  # 4-byte / 8-char Bambu Lab hardware UID
-            )
-
-        assert resp.status_code == 200
-        mock_client.update_spool_full.assert_called_once()
-        _, kwargs = mock_client.update_spool_full.call_args
-        assert kwargs.get("extra", {}).get("tag") == _json.dumps("2728C17B")

+ 0 - 365
backend/tests/integration/test_spoolman_ams_sync.py

@@ -1,365 +0,0 @@
-"""Integration tests for POST /api/v1/spoolman/inventory/sync-ams-weights.
-
-Covers:
-  - happy path: synced count incremented, update_spool_full called with correct weight
-  - printer offline: assignment skipped
-  - spool missing from Spoolman: assignment skipped
-  - invalid remain value: assignment skipped
-"""
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from httpx import AsyncClient
-
-SAMPLE_SPOOL = {
-    "id": 42,
-    "filament": {
-        "id": 1,
-        "name": "PLA Basic",
-        "material": "PLA",
-        "weight": 1000,
-        "color_hex": "FF0000",
-        "vendor": {"id": 1, "name": "BrandX"},
-    },
-    "remaining_weight": 800.0,
-    "used_weight": 200.0,
-    "location": None,
-    "comment": None,
-    "first_used": None,
-    "last_used": None,
-    "registered": "2024-01-01T00:00:00+00:00",
-    "archived": False,
-    "price": None,
-    "extra": {},
-}
-
-
-@pytest.fixture
-async def sync_settings(db_session):
-    from backend.app.models.settings import Settings
-
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-    await db_session.commit()
-
-
-@pytest.fixture
-async def test_printer(db_session):
-    from backend.app.models.printer import Printer
-
-    printer = Printer(
-        name="Sync Printer",
-        serial_number="SYNCTEST001",
-        ip_address="192.168.1.50",
-        access_code="12345678",
-    )
-    db_session.add(printer)
-    await db_session.commit()
-    await db_session.refresh(printer)
-    return printer
-
-
-@pytest.fixture
-async def slot_assignment(db_session, test_printer):
-    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-    assignment = SpoolmanSlotAssignment(
-        printer_id=test_printer.id,
-        ams_id=0,
-        tray_id=0,
-        spoolman_spool_id=42,
-    )
-    db_session.add(assignment)
-    await db_session.commit()
-    return assignment
-
-
-def _make_spoolman_client(spools=None):
-    client = MagicMock()
-    client.base_url = "http://localhost:7912"
-    client.health_check = AsyncMock(return_value=True)
-    client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOL] if spools is None else spools)
-    client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOL)
-    return client
-
-
-def _make_printer_state(remain=75):
-    state = MagicMock()
-    state.raw_data = {
-        "ams": [
-            {
-                "id": 0,
-                "tray": [{"id": 0, "remain": remain}],
-            }
-        ]
-    }
-    return state
-
-
-class TestSyncSpoolmanAmsWeights:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_happy_path_synced_count(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """POST /sync-ams-weights syncs one spool, returns synced=1, skipped=0."""
-        spoolman_client = _make_spoolman_client()
-        printer_state = _make_printer_state(remain=75)
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert response.status_code == 200
-        body = response.json()
-        assert body["synced"] == 1
-        assert body["skipped"] == 0
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_weight_calculated_correctly(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """Remaining weight = round(label_weight * remain / 100, 1)."""
-        spoolman_client = _make_spoolman_client()
-        printer_state = _make_printer_state(remain=75)
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        spoolman_client.update_spool_full.assert_called_once_with(42, remaining_weight=750.0)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_printer_offline_skipped(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """Spools whose printer is offline are counted as skipped, not synced."""
-        spoolman_client = _make_spoolman_client()
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=None)
-
-            response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert response.status_code == 200
-        body = response.json()
-        assert body["synced"] == 0
-        assert body["skipped"] == 1
-        spoolman_client.update_spool_full.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_spool_full_error_counted_as_skipped(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """update_spool_full raising HTTPException counts as skipped, not synced."""
-        from fastapi import HTTPException
-
-        spoolman_client = _make_spoolman_client()
-        spoolman_client.update_spool_full = AsyncMock(side_effect=HTTPException(status_code=503))
-        printer_state = _make_printer_state(remain=50)
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert response.status_code == 200
-        body = response.json()
-        assert body["synced"] == 0
-        assert body["skipped"] == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_invalid_remain_value_skipped(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """Non-numeric remain value in AMS data is counted as skipped."""
-        spoolman_client = _make_spoolman_client()
-        printer_state = _make_printer_state(remain="notanumber")
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert response.status_code == 200
-        body = response.json()
-        assert body["synced"] == 0
-        assert body["skipped"] == 1
-        spoolman_client.update_spool_full.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_missing_from_spoolman_skipped(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """Spools not present in Spoolman are counted as skipped."""
-        spoolman_client = _make_spoolman_client(spools=[])  # empty — spool 42 is gone
-        printer_state = _make_printer_state(remain=50)
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert response.status_code == 200
-        body = response.json()
-        assert body["synced"] == 0
-        assert body["skipped"] == 1
-
-
-# ---------------------------------------------------------------------------
-# F6: AMS sync edge cases
-# ---------------------------------------------------------------------------
-
-
-class TestSyncAmsEdgeCases:
-    """F6: Edge cases for remain values and AMS data format."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_null_remain_skipped(self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment):
-        """remain=null → tray skipped, synced=0 skipped=1."""
-        spoolman_client = _make_spoolman_client()
-        printer_state = _make_printer_state(remain=None)
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-            resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert resp.status_code == 200
-        body = resp.json()
-        assert body["synced"] == 0
-        assert body["skipped"] == 1
-        spoolman_client.update_spool_full.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_remain_above_100_skipped(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """remain=101 → out-of-range, tray skipped."""
-        spoolman_client = _make_spoolman_client()
-        printer_state = _make_printer_state(remain=101)
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-            resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert resp.status_code == 200
-        body = resp.json()
-        assert body["synced"] == 0
-        assert body["skipped"] == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_negative_remain_skipped(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """remain=-1 → out-of-range, tray skipped."""
-        spoolman_client = _make_spoolman_client()
-        printer_state = _make_printer_state(remain=-1)
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-            resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert resp.status_code == 200
-        body = resp.json()
-        assert body["synced"] == 0
-        assert body["skipped"] == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_dict_wrapped_ams_format(
-        self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
-    ):
-        """Double-nested ams format: raw_data['ams'] is a dict with 'ams' key → list."""
-        spoolman_client = _make_spoolman_client()
-
-        state = MagicMock()
-        state.raw_data = {
-            "ams": {
-                "ams": [
-                    {
-                        "id": 0,
-                        "tray": [{"id": 0, "remain": 75}],
-                    }
-                ]
-            }
-        }
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory._get_client",
-                AsyncMock(return_value=spoolman_client),
-            ),
-            patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
-        ):
-            pm_mock.get_status = MagicMock(return_value=state)
-            resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
-
-        assert resp.status_code == 200
-        body = resp.json()
-        assert body["synced"] == 1
-        assert body["skipped"] == 0

+ 13 - 670
backend/tests/integration/test_spoolman_api.py

@@ -40,12 +40,10 @@ class TestSpoolmanAPI:
         mock_client.base_url = "http://localhost:7912"
         mock_client.health_check = AsyncMock(return_value=True)
         mock_client.ensure_tag_extra_field = AsyncMock(return_value=True)
-        mock_client.ensure_extra_field = AsyncMock(return_value=True)
         mock_client.get_spools = AsyncMock(return_value=[])
         mock_client.get_filaments = AsyncMock(return_value=[])
         mock_client.create_spool = AsyncMock(return_value={"id": 1})
         mock_client.update_spool = AsyncMock(return_value={"id": 1})
-        mock_client.merge_spool_extra = AsyncMock(return_value={"id": 1, "extra": {}})
         mock_client.close = AsyncMock()
 
         with (
@@ -450,8 +448,8 @@ class TestSpoolmanAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_link_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
-        """Verify successfully linking a spool — uses merge_spool_extra to preserve custom fields."""
-        mock_spoolman_client.merge_spool_extra = AsyncMock(
+        """Verify successfully linking a spool to AMS tray."""
+        mock_spoolman_client.update_spool = AsyncMock(
             return_value={"id": 1, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
         )
 
@@ -464,75 +462,14 @@ class TestSpoolmanAPI:
         assert data["success"] is True
         assert "linked" in data["message"].lower()
 
-        mock_spoolman_client.merge_spool_extra.assert_called_once_with(1, {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'})
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_with_printer_context_creates_slot_assignment(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client, printer_factory
-    ):
-        """link with printer_id+ams_id+tray_id upserts into local slot-assignment table."""
-        mock_spoolman_client.merge_spool_extra = AsyncMock(
-            return_value={"id": 5, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
-        )
-        printer = await printer_factory()
-
-        response = await async_client.post(
-            "/api/v1/spoolman/spools/5/link",
-            json={
-                "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
-                "printer_id": printer.id,
-                "ams_id": 0,
-                "tray_id": 1,
-            },
-        )
-        assert response.status_code == 200
-
-        # Verify the slot assignment row was written via the /all endpoint
-        all_resp = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments/all",
-            params={"printer_id": printer.id},
-        )
-        assert all_resp.status_code == 200
-        rows = all_resp.json()
-        assert len(rows) == 1
-        assert rows[0]["spoolman_spool_id"] == 5
-        assert rows[0]["ams_id"] == 0
-        assert rows[0]["tray_id"] == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_without_printer_context_no_slot_assignment(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """link without printer context calls merge_spool_extra and no slot assignment is created."""
-        mock_spoolman_client.merge_spool_extra = AsyncMock(
-            return_value={"id": 5, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
-        )
-
-        response = await async_client.post(
-            "/api/v1/spoolman/spools/5/link",
-            json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"},
-        )
-        assert response.status_code == 200
-        mock_spoolman_client.merge_spool_extra.assert_called_once_with(5, {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'})
+        # Verify update_spool was called
+        mock_spoolman_client.update_spool.assert_called_once()
 
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_unlink_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
-        """Unlink clears extra.tag via merge_spool_extra with json-encoded empty string.
-
-        Spoolman PATCHes the extra dict by MERGING with the existing keys —
-        popping a key from a Python dict copy and PATCHing the rest doesn't
-        clear it. To actually clear we send the JSON-encoded empty string
-        ('""'); read-side filters strip the wrapping quotes via .strip('"')
-        so the spool drops out of get_linked_spools.
-        """
-        import json as _json
-
-        mock_spoolman_client.merge_spool_extra = AsyncMock(
-            return_value={"id": 1, "extra": {"tag": '""', "custom": "keep"}}
-        )
+        """Verify successfully unlinking a spool clears extra.tag."""
+        mock_spoolman_client.update_spool = AsyncMock(return_value={"id": 1, "extra": {"tag": '""'}})
 
         response = await async_client.post("/api/v1/spoolman/spools/1/unlink")
         assert response.status_code == 200
@@ -540,136 +477,11 @@ class TestSpoolmanAPI:
         assert data["success"] is True
         assert "unlinked" in data["message"].lower()
 
-        mock_spoolman_client.merge_spool_extra.assert_called_once_with(1, {"tag": _json.dumps("")})
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unlink_spool_no_deadlock(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
-        """Regression: unlink must NOT acquire extra_lock around merge_spool_extra.
-
-        merge_spool_extra acquires extra_lock(spool_id) internally, so wrapping
-        the call in another `async with client.extra_lock(spool_id)` deadlocks
-        — asyncio.Lock is non-reentrant. Pre-fix: every unlink request hung
-        indefinitely; the SpoolBuddy kiosk's "Unassign" button looked
-        unresponsive because the mutation isPending stayed true forever.
-
-        This test verifies the request completes promptly by mocking
-        merge_spool_extra to fail if the lock is already held by the caller —
-        if the caller still wraps merge_spool_extra in `client.extra_lock(...)`,
-        merge_spool_extra would block forever waiting for the lock.
-        """
-        # Real extra_lock dictionary so we can detect contention
-        import asyncio as _asyncio
-
-        real_lock = _asyncio.Lock()
-        mock_spoolman_client.extra_lock = MagicMock(return_value=real_lock)
-
-        async def fake_merge(spool_id, fields):
-            # If the route still wraps this call in `async with extra_lock(...)`,
-            # the lock will be held when this fires and we'll deadlock without
-            # the timeout. The wait_for asserts we get the lock fast.
-            await _asyncio.wait_for(real_lock.acquire(), timeout=2.0)
-            try:
-                return {"id": spool_id, "extra": {"tag": '""', **fields}}
-            finally:
-                real_lock.release()
-
-        mock_spoolman_client.merge_spool_extra = AsyncMock(side_effect=fake_merge)
-
-        # Cap the request at 5s to fail fast on a deadlock.
-        response = await _asyncio.wait_for(
-            async_client.post("/api/v1/spoolman/spools/1/unlink"),
-            timeout=5.0,
-        )
-        assert response.status_code == 200
-        mock_spoolman_client.merge_spool_extra.assert_called_once()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unlink_spool_deletes_slot_assignment(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client, printer_factory
-    ):
-        """unlink removes the local slot assignment for the spool."""
-        # link_spool calls merge_spool_extra; unlink_spool uses get_spool + update_spool_full.
-        mock_spoolman_client.merge_spool_extra = AsyncMock(
-            return_value={"id": 7, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
-        )
-        printer = await printer_factory()
-
-        # First link to create the slot assignment
-        await async_client.post(
-            "/api/v1/spoolman/spools/7/link",
-            json={
-                "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
-                "printer_id": printer.id,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-
-        # unlink_spool uses merge_spool_extra to clear the tag (Spoolman
-        # PATCH merges, so the tag must be sent as json.dumps("") not popped).
-        mock_spoolman_client.merge_spool_extra.reset_mock()
-        mock_spoolman_client.merge_spool_extra = AsyncMock(return_value={"id": 7, "extra": {"tag": '""'}})
-        response = await async_client.post("/api/v1/spoolman/spools/7/unlink")
-        assert response.status_code == 200
-
-        # Slot assignment must be gone
-        all_resp = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments/all",
-            params={"printer_id": printer.id},
-        )
-        assert all_resp.status_code == 200
-        assert all_resp.json() == []
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_spoolman_not_found(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """link returns 404 when Spoolman reports the spool does not exist."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanNotFoundError("not found"))
-
-        response = await async_client.post(
-            "/api/v1/spoolman/spools/99/link",
-            json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"},
-        )
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_spoolman_unavailable(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """link returns 503 when Spoolman is unreachable."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        mock_spoolman_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanUnavailableError("down"))
-
-        response = await async_client.post(
-            "/api/v1/spoolman/spools/1/link",
-            json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"},
+        mock_spoolman_client.update_spool.assert_called_once_with(
+            spool_id=1,
+            clear_location=True,
+            extra={"tag": '""'},
         )
-        assert response.status_code == 503
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unlink_spool_spoolman_not_found(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """unlink returns 404 when Spoolman reports the spool does not exist.
-
-        The endpoint calls merge_spool_extra directly (no longer get_spool +
-        update_spool_full), so the not-found surface lives there.
-        """
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanNotFoundError("not found"))
-
-        response = await async_client.post("/api/v1/spoolman/spools/99/unlink")
-        assert response.status_code == 404
 
     # =========================================================================
     # Sync Tests
@@ -740,164 +552,6 @@ class TestSpoolmanAPI:
             assert response.status_code == 404
             assert "not connected" in response.json()["detail"].lower()
 
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_sync_writes_slot_assignment_to_db(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        printer_factory,
-        db_session,
-    ):
-        """sync persists a slot assignment row for each successfully synced spool."""
-        from sqlalchemy import select
-
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-        from backend.app.services.spoolman import AMSTray
-
-        printer = await printer_factory()
-        synced_spool = {"id": 42, "filament": {"material": "PLA"}, "remaining_weight": 500}
-
-        fake_tray = AMSTray(
-            ams_id=0,
-            tray_id=2,
-            tray_type="PLA",
-            tray_sub_brands="PLA Basic",
-            tray_color="FF0000FF",
-            remain=80,
-            tag_uid="",
-            tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
-            tray_info_idx="",
-            tray_weight=1000,
-        )
-        mock_spoolman_client.parse_ams_tray = MagicMock(return_value=fake_tray)
-        mock_spoolman_client.sync_ams_tray = AsyncMock(return_value=synced_spool)
-
-        with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
-            mock_state = MagicMock()
-            mock_state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 2}]}]}
-            pm_mock.get_status = MagicMock(return_value=mock_state)
-
-            response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
-            assert response.status_code == 200
-            data = response.json()
-            assert data["synced_count"] == 1
-
-        # Verify slot assignment was written to the DB
-        result = await db_session.execute(
-            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer.id)
-        )
-        rows = result.scalars().all()
-        assert len(rows) == 1
-        assert rows[0].ams_id == 0
-        assert rows[0].tray_id == 2
-        assert rows[0].spoolman_spool_id == 42
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_sync_passes_slot_hint_when_no_rfid(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        printer_factory,
-        db_session,
-    ):
-        """sync passes the spoolman_spool_id_hint from the local slot-assignment table when no RFID tag is present."""
-        from sqlalchemy import text
-
-        from backend.app.services.spoolman import AMSTray
-
-        printer = await printer_factory()
-
-        # Pre-seed a slot assignment to serve as the hint
-        await db_session.execute(
-            text(
-                "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                " VALUES (:p, :a, :t, :s)"
-            ),
-            {"p": printer.id, "a": 0, "t": 1, "s": 55},
-        )
-        await db_session.commit()
-
-        captured_hints: list = []
-
-        async def capturing_sync(tray, printer_name, **kwargs):
-            captured_hints.append(kwargs.get("spoolman_spool_id_hint"))
-            return None
-
-        fake_tray_no_rfid = AMSTray(
-            ams_id=0,
-            tray_id=1,
-            tray_type="PLA",
-            tray_sub_brands="Generic PLA",
-            tray_color="FFFFFFFF",
-            remain=-1,
-            tag_uid="",
-            tray_uuid="",
-            tray_info_idx="",
-            tray_weight=1000,
-        )
-        mock_spoolman_client.parse_ams_tray = MagicMock(return_value=fake_tray_no_rfid)
-        mock_spoolman_client.sync_ams_tray = capturing_sync
-
-        with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
-            mock_state = MagicMock()
-            mock_state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 1}]}]}
-            pm_mock.get_status = MagicMock(return_value=mock_state)
-
-            response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
-            assert response.status_code == 200
-
-        assert len(captured_hints) == 1
-        assert captured_hints[0] == 55
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_sync_no_rfid_no_hint_produces_skipped_entry(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        printer_factory,
-    ):
-        """sync reports a SkippedSpool for a tray with no RFID tag and no prior slot assignment."""
-        from backend.app.services.spoolman import AMSTray
-
-        printer = await printer_factory()
-
-        fake_tray = AMSTray(
-            ams_id=0,
-            tray_id=3,
-            tray_type="ABS",
-            tray_sub_brands="Generic ABS",
-            tray_color="333333FF",
-            remain=60,
-            tag_uid="",
-            tray_uuid="",
-            tray_info_idx="",
-            tray_weight=1000,
-        )
-        mock_spoolman_client.parse_ams_tray = MagicMock(return_value=fake_tray)
-        mock_spoolman_client.sync_ams_tray = AsyncMock(return_value=None)
-
-        with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
-            mock_state = MagicMock()
-            mock_state.raw_data = {"ams": [{"id": 0, "tray": [{"id": 3}]}]}
-            pm_mock.get_status = MagicMock(return_value=mock_state)
-
-            response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
-            assert response.status_code == 200
-
-        data = response.json()
-        assert data["synced_count"] == 0
-        assert data["skipped_count"] == 1
-        assert len(data["skipped"]) == 1
-        skipped = data["skipped"][0]
-        assert "No RFID" in skipped["reason"]
-        assert skipped["filament_type"] == "ABS"
-
     # =========================================================================
     # Filaments Endpoint Tests
     # =========================================================================
@@ -996,14 +650,14 @@ class TestSpoolmanAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_sync_with_weight_sync_disabled_passes_flag(
+    async def test_sync_with_weight_sync_disabled_updates_location_only(
         self,
         async_client: AsyncClient,
         spoolman_settings_weight_sync_disabled,
         mock_spoolman_client,
         printer_factory,
     ):
-        """Verify sync passes disable_weight_sync=True to sync_ams_tray when the setting is on."""
+        """Verify sync only updates location when disable_weight_sync is enabled."""
         printer = await printer_factory()
 
         # Mock existing spool
@@ -1032,6 +686,7 @@ class TestSpoolmanAPI:
             tray_weight=1000,
         )
         mock_spoolman_client.parse_ams_tray.return_value = mock_tray
+        mock_spoolman_client.is_bambu_lab_spool = MagicMock(return_value=True)
         mock_spoolman_client.convert_ams_slot_to_location = MagicMock(return_value="AMS A1")
         mock_spoolman_client.sync_ams_tray = AsyncMock(return_value={"id": 42})
         mock_spoolman_client.clear_location_for_removed_spools = AsyncMock(return_value=0)
@@ -1136,315 +791,3 @@ class TestSpoolmanAPI:
         data = response.json()
         # Should default to "true"
         assert data["spoolman_report_partial_usage"] == "true"
-
-
-class TestLinkSpoolMqttConfigure:
-    """P9-TEST-BE (Bug #8): link_spool sends MQTT configure when printer context is provided."""
-
-    @pytest.fixture
-    async def spoolman_settings(self, db_session):
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-        await db_session.commit()
-
-    @pytest.fixture
-    def mock_spoolman_client(self):
-        mock_client = MagicMock()
-        mock_client.is_connected = True
-        mock_client.base_url = "http://localhost:7912"
-        mock_client.health_check = AsyncMock(return_value=True)
-        mock_client.ensure_tag_extra_field = AsyncMock(return_value=True)
-        mock_client.merge_spool_extra = AsyncMock(
-            return_value={"id": 5, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
-        )
-        mock_client.get_spool = AsyncMock(
-            return_value={
-                "id": 5,
-                "remaining_weight": 800.0,
-                "used_weight": 200.0,
-                "spool_weight": None,
-                "filament": {
-                    "id": 1,
-                    "name": "PLA Basic",
-                    "material": "PLA",
-                    "color_hex": "FF0000",
-                    "color_name": "Red",
-                    "vendor": {"id": 1, "name": "Bambu Lab"},
-                    "weight": 1000,
-                    "spool_weight": 250,
-                },
-                "extra": {},
-                "location": None,
-                "comment": None,
-                "archived": False,
-            }
-        )
-        mock_client.close = AsyncMock()
-        mock_client.extra_lock = MagicMock()
-        mock_client.extra_lock.return_value.__aenter__ = AsyncMock(return_value=None)
-        mock_client.extra_lock.return_value.__aexit__ = AsyncMock(return_value=False)
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.api.routes.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.api.routes.spoolman.close_spoolman_client",
-                AsyncMock(),
-            ),
-        ):
-            yield mock_client
-
-    @pytest.fixture
-    def printer_factory(self, db_session):
-        _counter = [0]
-
-        async def _create(**kwargs):
-            from backend.app.models.printer import Printer
-
-            _counter[0] += 1
-            defaults = {
-                "name": f"Test Printer {_counter[0]}",
-                "serial_number": f"MQTTTEST{_counter[0]:06d}",
-                "ip_address": f"192.168.100.{_counter[0]}",
-                "access_code": "12345678",
-                "is_active": True,
-                "auto_archive": True,
-                "model": "X1C",
-            }
-            defaults.update(kwargs)
-            p = Printer(**defaults)
-            db_session.add(p)
-            await db_session.commit()
-            await db_session.refresh(p)
-            return p
-
-        return _create
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_sends_ams_set_filament_with_printer_context(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client, printer_factory
-    ):
-        """link_spool with printer context calls ams_set_filament_setting via MQTT."""
-        printer = await printer_factory()
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.printer_state = MagicMock(
-            nozzles=[MagicMock(nozzle_diameter="0.4")],
-            ams_extruder_map={"0": 0},
-            raw_data={"ams": [{"id": 0, "tray": [{"id": 1, "cali_idx": None}]}]},
-        )
-
-        with patch("backend.app.api.routes.spoolman.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mqtt_mock
-            response = await async_client.post(
-                "/api/v1/spoolman/spools/5/link",
-                json={
-                    "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
-                    "printer_id": printer.id,
-                    "ams_id": 0,
-                    "tray_id": 1,
-                },
-            )
-
-        assert response.status_code == 200
-        assert response.json()["success"] is True
-        mqtt_mock.ams_set_filament_setting.assert_called_once()
-        call_kwargs = mqtt_mock.ams_set_filament_setting.call_args.kwargs
-        assert call_kwargs["ams_id"] == 0
-        assert call_kwargs["tray_id"] == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_no_printer_context_no_mqtt(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """link_spool without printer context does not attempt MQTT configure."""
-        mqtt_mock = MagicMock()
-
-        with patch("backend.app.api.routes.spoolman.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mqtt_mock
-            response = await async_client.post(
-                "/api/v1/spoolman/spools/5/link",
-                json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"},
-            )
-
-        assert response.status_code == 200
-        mock_pm.get_client.assert_not_called()
-        mqtt_mock.ams_set_filament_setting.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_mqtt_failure_does_not_prevent_link(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client, printer_factory
-    ):
-        """MQTT failure is best-effort: link still succeeds if ams_set_filament_setting throws."""
-        printer = await printer_factory()
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.printer_state = MagicMock(
-            nozzles=[MagicMock(nozzle_diameter="0.4")],
-            ams_extruder_map={"0": 0},
-            raw_data={"ams": []},
-        )
-        mqtt_mock.ams_set_filament_setting.side_effect = RuntimeError("MQTT connection lost")
-
-        with patch("backend.app.api.routes.spoolman.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mqtt_mock
-            response = await async_client.post(
-                "/api/v1/spoolman/spools/5/link",
-                json={
-                    "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
-                    "printer_id": printer.id,
-                    "ams_id": 0,
-                    "tray_id": 0,
-                },
-            )
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["success"] is True
-        assert "linked" in data["message"].lower()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_realigns_filament_context_to_printer_kp(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        printer_factory,
-        db_session,
-    ):
-        """link_spool MQTT auto-configure realigns tray_info_idx + setting_id
-        to the printer-side kp's filament context — same fix as assign path.
-
-        Pre-fix link_spool used `mqtt_client.printer_state` (a non-existent
-        attribute that always evaluated to None), so state.kprofiles /
-        nozzles / ams_extruder_map were all skipped — every link sent
-        generic-PLA tray_info_idx with empty setting_id, and the cali_idx
-        in the printer's table couldn't be linked. (#1114)
-        """
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        printer = await printer_factory()
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=5,
-            printer_id=printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.025,
-            cali_idx=8948,
-            setting_id="PFUSedbf16b803ff3e",
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        printer_kp = MagicMock(
-            slot_id=8948,
-            nozzle_diameter="0.4",
-            filament_id="P4d64437",
-            setting_id="PFUSedbf16b803ff3e",
-        )
-        printer_state = MagicMock(
-            nozzles=[MagicMock(nozzle_diameter="0.4")],
-            ams_extruder_map={"0": 0},
-            raw_data={"ams": [{"id": 0, "tray": [{"id": 1, "cali_idx": None}]}]},
-            kprofiles=[printer_kp],
-        )
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Production never had this attribute; pre-fix code read it and got
-        # None, defeating the cascade. The new code uses get_status instead.
-        mqtt_mock.printer_state = None
-
-        with patch("backend.app.api.routes.spoolman.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mqtt_mock
-            mock_pm.get_status.return_value = printer_state
-            response = await async_client.post(
-                "/api/v1/spoolman/spools/5/link",
-                json={
-                    "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
-                    "printer_id": printer.id,
-                    "ams_id": 0,
-                    "tray_id": 1,
-                },
-            )
-
-        assert response.status_code == 200
-        amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args.kwargs
-        assert amf_kwargs["tray_info_idx"] == "P4d64437"
-        assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
-        cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args.kwargs
-        assert cs_kwargs["cali_idx"] == 8948
-        assert cs_kwargs["filament_id"] == "P4d64437"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_spool_uses_printer_manager_not_mqtt_client_state(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        printer_factory,
-        db_session,
-    ):
-        """Regression: state comes from printer_manager.get_status, not
-        mqtt_client.printer_state (which didn't exist on the real client)."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        printer = await printer_factory()
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=5,
-            printer_id=printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.025,
-            cali_idx=42,
-            setting_id="GFSL05",
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        printer_state = MagicMock(
-            nozzles=[MagicMock(nozzle_diameter="0.4")],
-            ams_extruder_map={"0": 0},
-            raw_data=None,
-            kprofiles=[],
-        )
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Production didn't have mqtt_client.printer_state — drop the spec
-        # so an accidental read raises AttributeError instead of silently
-        # returning a MagicMock.
-        del mqtt_mock.printer_state
-
-        with patch("backend.app.api.routes.spoolman.printer_manager") as mock_pm:
-            mock_pm.get_client.return_value = mqtt_mock
-            mock_pm.get_status.return_value = printer_state
-            response = await async_client.post(
-                "/api/v1/spoolman/spools/5/link",
-                json={
-                    "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
-                    "printer_id": printer.id,
-                    "ams_id": 0,
-                    "tray_id": 2,
-                },
-            )
-
-        assert response.status_code == 200
-        # cali_sel must fire with cali_idx=42 — proves get_status was used
-        mqtt_mock.extrusion_cali_sel.assert_called_once()
-        assert mqtt_mock.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 42

+ 0 - 527
backend/tests/integration/test_spoolman_filament_patch.py

@@ -1,527 +0,0 @@
-"""Integration tests for PATCH /spoolman/inventory/filaments/{filament_id}.
-
-Covers:
-- Option A (keep_existing_spools=True): stamps old filament weight onto spools that currently inherit
-- Option B (keep_existing_spools=False): clears per-spool overrides in Spoolman so all inherit new value
-- Name-only patch: no get_all_spools call
-- Edge cases: disabled Spoolman, not found, invalid inputs
-- Spool-level tare priority in sync_spool_weight (spoolman_inventory) and update_spool_weight (spoolbuddy)
-"""
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from httpx import AsyncClient
-
-SAMPLE_FILAMENT = {
-    "id": 7,
-    "name": "PLA Basic",
-    "material": "PLA",
-    "color_hex": "FF0000",
-    "color_name": "Red",
-    "weight": 1000,
-    "spool_weight": 250.0,
-    "vendor": {"id": 3, "name": "Bambu Lab"},
-}
-
-SAMPLE_SPOOL_WITH_FILAMENT_7 = {
-    "id": 42,
-    "spool_weight": None,  # inheriting from filament
-    "filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "spool_weight": 250.0, "weight": 1000},
-    "remaining_weight": 750.0,
-    "used_weight": 250.0,
-    "location": None,
-    "comment": None,
-    "archived": False,
-    "extra": {},
-}
-
-SAMPLE_SPOOL_WITH_FILAMENT_99 = {
-    "id": 55,
-    "spool_weight": 196.0,  # has its own spool-level override
-    "filament": {"id": 99, "name": "PETG HF", "material": "PETG", "spool_weight": 196.0, "weight": 1000},
-    "remaining_weight": 500.0,
-    "used_weight": 500.0,
-    "location": None,
-    "comment": None,
-    "archived": False,
-    "extra": {},
-}
-
-SPOOL_WITH_NULL_FILAMENT = {
-    "id": 77,
-    "spool_weight": None,
-    "filament": None,
-    "remaining_weight": 100.0,
-    "used_weight": 900.0,
-    "location": None,
-    "comment": None,
-    "archived": False,
-    "extra": {},
-}
-
-SAMPLE_SPOOL_7_WITH_OVERRIDE = {
-    "id": 43,
-    "spool_weight": 300.0,  # has its own spool-level override
-    "filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "spool_weight": 250.0, "weight": 1000},
-    "remaining_weight": 700.0,
-    "used_weight": 300.0,
-    "location": None,
-    "comment": None,
-    "archived": False,
-    "extra": {},
-}
-
-
-@pytest.fixture
-async def spoolman_settings(db_session):
-    from backend.app.models.settings import Settings
-
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-    await db_session.commit()
-
-
-def make_mock_client(filament=None, all_spools=None, patched_filament=None):
-    mock_client = MagicMock()
-    mock_client.base_url = "http://localhost:7912"
-    mock_client.get_filament = AsyncMock(return_value=filament or SAMPLE_FILAMENT)
-    mock_client.patch_filament = AsyncMock(return_value=patched_filament or SAMPLE_FILAMENT)
-    mock_client.get_all_spools = AsyncMock(
-        return_value=all_spools if all_spools is not None else [SAMPLE_SPOOL_WITH_FILAMENT_7]
-    )
-    mock_client.update_spool_full = AsyncMock(return_value={})
-    return mock_client
-
-
-# ---------------------------------------------------------------------------
-# PATCH /filaments/{id} — core scenarios
-# ---------------------------------------------------------------------------
-
-
-class TestPatchFilamentOptionB:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_option_b_stamps_new_weight_on_all_affected_spools(
-        self, async_client: AsyncClient, spoolman_settings
-    ):
-        """Option B: ALL affected spools (inheriting and overridden alike) get the new weight stamped."""
-        mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_7_WITH_OVERRIDE])
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0, "keep_existing_spools": False},
-            )
-
-        assert response.status_code == 200
-        mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": 196.0})
-        assert mock_client.update_spool_full.call_count == 2
-        calls = {c.kwargs["spool_id"]: c.kwargs["spool_weight"] for c in mock_client.update_spool_full.call_args_list}
-        assert calls[42] == pytest.approx(196.0)
-        assert calls[43] == pytest.approx(196.0)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_option_b_stamps_inheriting_spool_with_new_weight(self, async_client: AsyncClient, spoolman_settings):
-        """Option B: a spool inheriting (spool_weight=None) gets the new weight explicitly stamped."""
-        mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7])
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0, "keep_existing_spools": False},
-            )
-
-        assert response.status_code == 200
-        mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(196.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_option_b_only_stamps_affected_filament_spools(self, async_client: AsyncClient, spoolman_settings):
-        """Option B for filament 7 must not touch spools belonging to other filament types."""
-        mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_WITH_FILAMENT_99])
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0, "keep_existing_spools": False},
-            )
-
-        assert response.status_code == 200
-        # Only spool 42 (filament 7) should be stamped; spool 55 (filament 99) must not be touched
-        mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(196.0))
-
-
-class TestPatchFilamentOptionA:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_option_a_stamps_old_weight_on_inheriting_spools(self, async_client: AsyncClient, spoolman_settings):
-        """Option A: spools inheriting from filament (spool_weight=None) get old weight stamped on them."""
-        mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7])
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0, "keep_existing_spools": True},
-            )
-
-        assert response.status_code == 200
-        # old_weight = SAMPLE_FILAMENT["spool_weight"] = 250.0
-        mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_option_a_does_not_patch_spools_with_existing_override(
-        self, async_client: AsyncClient, spoolman_settings
-    ):
-        """Option A: spools already having their own spool_weight are left unchanged."""
-        mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_7_WITH_OVERRIDE])
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0, "keep_existing_spools": True},
-            )
-
-        assert response.status_code == 200
-        mock_client.update_spool_full.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_option_a_mixed_spools_stamps_only_inheriting(self, async_client: AsyncClient, spoolman_settings):
-        """Option A: only inheriting spools (spool_weight=None) get old weight; overridden spools are skipped."""
-        mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_7_WITH_OVERRIDE])
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0, "keep_existing_spools": True},
-            )
-
-        assert response.status_code == 200
-        # Only spool 42 (inheriting) should be stamped; spool 43 (has override) must not be touched
-        mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_option_a_zero_spools_no_error(self, async_client: AsyncClient, spoolman_settings):
-        """Option A with zero spools for this filament: no error, no Spoolman calls."""
-        mock_client = make_mock_client(all_spools=[])
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0, "keep_existing_spools": True},
-            )
-
-        assert response.status_code == 200
-        mock_client.update_spool_full.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_option_a_filament_no_old_weight_skips_stamping(self, async_client: AsyncClient, spoolman_settings):
-        """Option A: if the filament has no old spool_weight, no stamping occurs."""
-        mock_client = make_mock_client(filament={**SAMPLE_FILAMENT, "spool_weight": None})
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0, "keep_existing_spools": True},
-            )
-
-        assert response.status_code == 200
-        mock_client.update_spool_full.assert_not_called()
-
-
-class TestPatchFilamentNameOnly:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_name_only_patch_no_get_all_spools(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """Patching name only must not call get_all_spools."""
-        mock_client = make_mock_client()
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"name": "PLA Basic Renamed"},
-            )
-
-        assert response.status_code == 200
-        mock_client.patch_filament.assert_called_once_with(7, {"name": "PLA Basic Renamed"})
-        mock_client.get_all_spools.assert_not_called()
-
-
-class TestPatchFilamentEdgeCases:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_null_filament_on_spool_skipped(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """Spools with filament=null are skipped without error."""
-        mock_client = make_mock_client(all_spools=[SPOOL_WITH_NULL_FILAMENT, SAMPLE_SPOOL_WITH_FILAMENT_7])
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0},
-            )
-
-        assert response.status_code == 200
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_weight_zero_is_valid(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """spool_weight=0 is valid (0g tare weight is legitimate)."""
-        mock_client = make_mock_client()
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 0},
-            )
-
-        assert response.status_code == 200
-        mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": 0})
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_weight_null_removes_weight(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """spool_weight=null is forwarded to Spoolman as None."""
-        mock_client = make_mock_client()
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": None},
-            )
-
-        assert response.status_code == 200
-        mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": None})
-
-
-class TestPatchFilamentErrors:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_disabled_returns_400(self, async_client: AsyncClient, db_session):
-        """When Spoolman is disabled, PATCH /filaments/{id} returns 400."""
-        response = await async_client.patch(
-            "/api/v1/spoolman/inventory/filaments/7",
-            json={"spool_weight": 196.0},
-        )
-        assert response.status_code == 400
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_not_found_returns_404(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """When get_filament raises SpoolmanNotFoundError, endpoint returns 404."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_client = make_mock_client()
-        mock_client.get_filament = AsyncMock(side_effect=SpoolmanNotFoundError("not found"))
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": 196.0},
-            )
-
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_invalid_id_returns_422(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """filament_id=0 fails Path validation (gt=0) with 422."""
-        response = await async_client.patch(
-            "/api/v1/spoolman/inventory/filaments/0",
-            json={"spool_weight": 196.0},
-        )
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_negative_spool_weight_returns_422(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """spool_weight=-1 fails Pydantic validation (ge=0.0) with 422."""
-        mock_client = make_mock_client()
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/filaments/7",
-                json={"spool_weight": -1},
-            )
-        assert response.status_code == 422
-
-
-# ---------------------------------------------------------------------------
-# Spool-level tare priority in sync_spool_weight (spoolman_inventory)
-# ---------------------------------------------------------------------------
-
-
-class TestSyncSpoolWeightPriority:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
-        """sync_spool_weight uses spool.spool_weight over filament.spool_weight for tare."""
-        spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 100.0}
-        mock_client = make_mock_client()
-        mock_client.get_spool = AsyncMock(return_value=spool_data)
-        mock_client.update_spool_full = AsyncMock(return_value=spool_data)
-
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/spools/42/weight",
-                json={"weight_grams": 600.0},
-            )
-
-        assert response.status_code == 200
-        # remaining = 600 - 100 (spool-level tare) = 500
-        update_call = mock_client.update_spool_full.call_args
-        assert update_call.kwargs["remaining_weight"] == pytest.approx(500.0)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_filament_spool_weight_used_as_fallback(self, async_client: AsyncClient, spoolman_settings):
-        """sync_spool_weight falls back to filament.spool_weight when spool.spool_weight is None."""
-        spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7}  # spool_weight=None → filament fallback 250.0
-        mock_client = make_mock_client()
-        mock_client.get_spool = AsyncMock(return_value=spool_data)
-        mock_client.update_spool_full = AsyncMock(return_value=spool_data)
-
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/spools/42/weight",
-                json={"weight_grams": 600.0},
-            )
-
-        assert response.status_code == 200
-        # remaining = 600 - 250 (filament.spool_weight) = 350
-        update_call = mock_client.update_spool_full.call_args
-        assert update_call.kwargs["remaining_weight"] == pytest.approx(350.0)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_level_zero_not_treated_as_missing(self, async_client: AsyncClient, spoolman_settings):
-        """spool.spool_weight=0 is a valid 0g tare, not treated as missing."""
-        spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 0}
-        mock_client = make_mock_client()
-        mock_client.get_spool = AsyncMock(return_value=spool_data)
-        mock_client.update_spool_full = AsyncMock(return_value=spool_data)
-
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/spools/42/weight",
-                json={"weight_grams": 600.0},
-            )
-
-        assert response.status_code == 200
-        # remaining = 600 - 0 = 600 (not 600 - 250 fallback)
-        update_call = mock_client.update_spool_full.call_args
-        assert update_call.kwargs["remaining_weight"] == pytest.approx(600.0)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_both_levels_none_uses_250g_fallback(self, async_client: AsyncClient, spoolman_settings):
-        """When both spool.spool_weight and filament.spool_weight are None, 250g fallback is used."""
-        spool_data = {
-            **SAMPLE_SPOOL_WITH_FILAMENT_7,
-            "spool_weight": None,
-            "filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None},
-        }
-        mock_client = make_mock_client()
-        mock_client.get_spool = AsyncMock(return_value=spool_data)
-        mock_client.update_spool_full = AsyncMock(return_value=spool_data)
-
-        with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
-            response = await async_client.patch(
-                "/api/v1/spoolman/inventory/spools/42/weight",
-                json={"weight_grams": 600.0},
-            )
-
-        assert response.status_code == 200
-        # remaining = 600 - 250 (fallback) = 350
-        update_call = mock_client.update_spool_full.call_args
-        assert update_call.kwargs["remaining_weight"] == pytest.approx(350.0)
-
-
-# ---------------------------------------------------------------------------
-# Spool-level tare priority in update_spool_weight (spoolbuddy.py scale endpoint)
-# ---------------------------------------------------------------------------
-
-
-class TestUpdateSpoolWeightPriority:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
-        """update_spool_weight uses spool.spool_weight over filament.spool_weight for tare."""
-        spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 100.0}
-        mock_client = MagicMock()
-        mock_client.get_spool = AsyncMock(return_value=spool_data)
-        mock_client.update_spool = AsyncMock(return_value=None)
-
-        with patch(
-            "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
-            AsyncMock(return_value=mock_client),
-        ):
-            response = await async_client.post(
-                "/api/v1/spoolbuddy/scale/update-spool-weight",
-                json={"spool_id": 42, "weight_grams": 600.0},
-            )
-
-        assert response.status_code == 200
-        # remaining = 600 - 100 (spool-level tare) = 500
-        mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(500.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_filament_spool_weight_used_as_fallback(self, async_client: AsyncClient, spoolman_settings):
-        """update_spool_weight falls back to filament.spool_weight when spool.spool_weight is None."""
-        spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7}  # spool_weight=None → filament fallback 250.0
-        mock_client = MagicMock()
-        mock_client.get_spool = AsyncMock(return_value=spool_data)
-        mock_client.update_spool = AsyncMock(return_value=None)
-
-        with patch(
-            "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
-            AsyncMock(return_value=mock_client),
-        ):
-            response = await async_client.post(
-                "/api/v1/spoolbuddy/scale/update-spool-weight",
-                json={"spool_id": 42, "weight_grams": 600.0},
-            )
-
-        assert response.status_code == 200
-        # remaining = 600 - 250 (filament.spool_weight) = 350
-        mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spool_level_zero_not_treated_as_missing(self, async_client: AsyncClient, spoolman_settings):
-        """spool.spool_weight=0 is a valid 0g tare, not treated as missing."""
-        spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 0}
-        mock_client = MagicMock()
-        mock_client.get_spool = AsyncMock(return_value=spool_data)
-        mock_client.update_spool = AsyncMock(return_value=None)
-
-        with patch(
-            "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
-            AsyncMock(return_value=mock_client),
-        ):
-            response = await async_client.post(
-                "/api/v1/spoolbuddy/scale/update-spool-weight",
-                json={"spool_id": 42, "weight_grams": 600.0},
-            )
-
-        assert response.status_code == 200
-        # remaining = 600 - 0 = 600 (not 600 - 250 fallback)
-        mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(600.0))
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_both_levels_none_uses_250g_fallback_and_warns(self, async_client: AsyncClient, spoolman_settings):
-        """When both spool.spool_weight and filament.spool_weight are None, 250g fallback is used with a warning."""
-        spool_data = {
-            **SAMPLE_SPOOL_WITH_FILAMENT_7,
-            "spool_weight": None,
-            "filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None},
-        }
-        mock_client = MagicMock()
-        mock_client.get_spool = AsyncMock(return_value=spool_data)
-        mock_client.update_spool = AsyncMock(return_value=None)
-
-        with patch(
-            "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
-            AsyncMock(return_value=mock_client),
-        ):
-            response = await async_client.post(
-                "/api/v1/spoolbuddy/scale/update-spool-weight",
-                json={"spool_id": 42, "weight_grams": 600.0},
-            )
-
-        assert response.status_code == 200
-        # remaining = 600 - 250 (fallback) = 350
-        mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
-        assert response.json().get("warnings")

+ 0 - 2526
backend/tests/integration/test_spoolman_inventory_api.py

@@ -1,2526 +0,0 @@
-"""Integration tests for the Spoolman inventory proxy endpoints.
-
-These tests verify that /api/v1/spoolman/inventory/spools/* correctly
-translates between Spoolman's data model and Bambuddy's InventorySpool format.
-"""
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from fastapi import HTTPException
-from httpx import AsyncClient
-
-# ---------------------------------------------------------------------------
-# Shared fixtures
-# ---------------------------------------------------------------------------
-
-SAMPLE_SPOOLMAN_SPOOL = {
-    "id": 42,
-    "filament": {
-        "id": 7,
-        "name": "PLA Basic",
-        "material": "PLA",
-        "color_hex": "FF0000",
-        "weight": 1000,
-        "vendor": {"id": 3, "name": "Bambu Lab"},
-    },
-    "remaining_weight": 750.0,
-    "used_weight": 250.0,
-    "location": "Printer1 - AMS A1",
-    "comment": "test note",
-    "first_used": "2024-01-01T00:00:00+00:00",
-    "last_used": "2024-02-01T00:00:00+00:00",
-    "registered": "2024-01-01T00:00:00+00:00",
-    "archived": False,
-    "price": None,
-    "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'},
-}
-
-
-@pytest.fixture
-async def spoolman_settings(db_session):
-    """Create Spoolman settings in the database (enabled with URL)."""
-    from backend.app.models.settings import Settings
-
-    enabled_setting = Settings(key="spoolman_enabled", value="true")
-    url_setting = Settings(key="spoolman_url", value="http://localhost:7912")
-    db_session.add(enabled_setting)
-    db_session.add(url_setting)
-    await db_session.commit()
-    return {"enabled": enabled_setting, "url": url_setting}
-
-
-@pytest.fixture
-def mock_spoolman_client():
-    """Mock the Spoolman client with a sample spool."""
-    mock_client = MagicMock()
-    mock_client.base_url = "http://localhost:7912"
-    mock_client.health_check = AsyncMock(return_value=True)
-    mock_client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL])
-    mock_client.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-    mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-    mock_client.delete_spool = AsyncMock(return_value=True)
-    mock_client.set_spool_archived = AsyncMock(
-        side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
-    )
-    mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-    mock_client.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-    mock_client.find_or_create_filament = AsyncMock(return_value=7)
-
-    with (
-        patch(
-            "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
-            AsyncMock(return_value=mock_client),
-        ),
-        patch(
-            "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
-            AsyncMock(return_value=mock_client),
-        ),
-    ):
-        yield mock_client
-
-
-class TestSpoolmanInventoryMapping:
-    """Tests for the Spoolman → InventorySpool data mapping."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_spools_returns_inventory_format(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """GET /spoolman/inventory/spools returns spools in InventorySpool format."""
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-
-        assert response.status_code == 200
-        spools = response.json()
-        assert isinstance(spools, list)
-        assert len(spools) == 1
-
-        spool = spools[0]
-        assert spool["id"] == 42
-        assert spool["material"] == "PLA"
-        assert spool["subtype"] == "Basic"
-        assert spool["brand"] == "Bambu Lab"
-        assert spool["label_weight"] == 1000
-        assert spool["weight_used"] == 250.0
-        assert spool["note"] == "test note"
-        assert spool["data_origin"] == "spoolman"
-        assert spool["tag_type"] == "spoolman"
-        # RRGGBB + FF alpha
-        assert spool["rgba"] == "FF0000FF"
-        # Spoolman location mapped to storage_location
-        assert spool["storage_location"] == "Printer1 - AMS A1"
-        # RFID tag: 32-char → tray_uuid
-        assert spool["tray_uuid"] == "AABBCCDDEEFF0011AABBCCDDEEFF0011"
-        assert spool["tag_uid"] is None
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_single_spool(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """GET /spoolman/inventory/spools/{id} returns a single spool."""
-        response = await async_client.get("/api/v1/spoolman/inventory/spools/42")
-
-        assert response.status_code == 200
-        spool = response.json()
-        assert spool["id"] == 42
-        assert spool["material"] == "PLA"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_includes_archived_when_requested(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """GET /spoolman/inventory/spools?include_archived=true calls Spoolman with allow_archived."""
-        await async_client.get("/api/v1/spoolman/inventory/spools?include_archived=true")
-        mock_spoolman_client.get_all_spools.assert_called_once_with(allow_archived=True)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_archived_spool_has_archived_at(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """An archived Spoolman spool maps to archived_at != None."""
-        archived_spool = {
-            **SAMPLE_SPOOLMAN_SPOOL,
-            "archived": True,
-        }
-        mock_spoolman_client.get_all_spools.return_value = [archived_spool]
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools?include_archived=true")
-        spool = response.json()[0]
-        assert spool["archived_at"] is not None
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_malformed_spool_skipped_in_list(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """A spool with an invalid id (e.g. 0) is silently skipped; others still appear."""
-        bad_spool = {**SAMPLE_SPOOLMAN_SPOOL, "id": 0}
-        mock_spoolman_client.get_all_spools.return_value = [bad_spool, SAMPLE_SPOOLMAN_SPOOL]
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert response.status_code == 200
-        spools = response.json()
-        # bad_spool is dropped; the valid one survives
-        assert len(spools) == 1
-        assert spools[0]["id"] == 42
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_spools_returns_503_when_spoolman_unavailable(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """GET /spoolman/inventory/spools returns 503 when Spoolman is unreachable (H10)."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        mock_spoolman_client.get_all_spools.side_effect = SpoolmanUnavailableError("down")
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert response.status_code == 503
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_uid_16char_maps_correctly(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """A 16-char tag maps to tag_uid, not tray_uuid."""
-        spool_with_short_tag = {
-            **SAMPLE_SPOOLMAN_SPOOL,
-            "extra": {"tag": '"AABBCCDDEEFF0011"'},
-        }
-        mock_spoolman_client.get_all_spools.return_value = [spool_with_short_tag]
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        spool = response.json()[0]
-        assert spool["tag_uid"] == "AABBCCDDEEFF0011"
-        assert spool["tray_uuid"] is None
-
-
-class TestSpoolmanInventoryCRUD:
-    """Tests for create, update, delete, archive, restore operations."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_not_enabled_returns_400(self, async_client: AsyncClient):
-        """All endpoints return 400 when Spoolman is not enabled."""
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert response.status_code == 400
-        assert "not enabled" in response.json()["detail"].lower()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_spool(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """POST /spoolman/inventory/spools creates a spool via Spoolman."""
-        payload = {
-            "material": "PLA",
-            "subtype": "Basic",
-            "brand": "Bambu Lab",
-            "rgba": "FF0000FF",
-            "label_weight": 1000,
-            "weight_used": 0,
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-
-        assert response.status_code == 200
-        mock_spoolman_client.find_or_create_filament.assert_called_once()
-        mock_spoolman_client.create_spool.assert_called_once()
-        data = response.json()
-        assert data["material"] == "PLA"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bulk_create_spools(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """POST /spoolman/inventory/spools/bulk creates multiple spools."""
-        payload = {
-            "spool": {"material": "PETG", "label_weight": 1000, "weight_used": 0},
-            "quantity": 3,
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
-
-        assert response.status_code == 200
-        assert mock_spoolman_client.create_spool.call_count == 3
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bulk_create_quantity_out_of_range_returns_422(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Bulk create quantity outside 1-50 is rejected with 422 (not silently clamped)."""
-        payload = {
-            "spool": {"material": "ABS", "label_weight": 1000, "weight_used": 0},
-            "quantity": 999,
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bulk_create_quantity_zero_returns_422(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Bulk create quantity of 0 is rejected with 422."""
-        payload = {
-            "spool": {"material": "ABS", "label_weight": 1000, "weight_used": 0},
-            "quantity": 0,
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_spool(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH /spoolman/inventory/spools/{id} updates a spool."""
-        payload = {"note": "updated note", "weight_used": 100.0}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-
-        assert response.status_code == 200
-        mock_spoolman_client.update_spool_full.assert_called_once()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_spool_not_found(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH returns 404 when Spoolman spool does not exist."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.get_spool.side_effect = SpoolmanNotFoundError("spool not found")
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/999", json={"note": "x"})
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_delete_spool(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """DELETE /spoolman/inventory/spools/{id} deletes a spool."""
-        response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
-
-        assert response.status_code == 200
-        assert response.json()["status"] == "deleted"
-        mock_spoolman_client.delete_spool.assert_called_once_with(42)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_delete_spool_failure(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """DELETE returns 503 when Spoolman is unreachable."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        mock_spoolman_client.delete_spool.side_effect = SpoolmanUnavailableError("unreachable")
-        response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
-        assert response.status_code == 503
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_delete_spool_not_found(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """DELETE returns 404 when Spoolman reports the spool does not exist."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.delete_spool.side_effect = SpoolmanNotFoundError("gone")
-        response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_archive_spool_not_found(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """POST /archive returns 404 when Spoolman reports the spool does not exist."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.set_spool_archived.side_effect = SpoolmanNotFoundError("gone")
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_restore_spool_not_found(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """POST /restore returns 404 when Spoolman reports the spool does not exist."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.set_spool_archived.side_effect = SpoolmanNotFoundError("gone")
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/restore")
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_archive_spool(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """POST /spoolman/inventory/spools/{id}/archive archives a spool."""
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
-
-        assert response.status_code == 200
-        mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=True)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_restore_spool(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """POST /spoolman/inventory/spools/{id}/restore restores an archived spool."""
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/restore")
-
-        assert response.status_code == 200
-        mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=False)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_sync_weight(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH /spoolman/inventory/spools/{id}/weight updates remaining weight."""
-        payload = {"weight_grams": 850.0}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json=payload)
-
-        assert response.status_code == 200
-        result = response.json()
-        assert result["status"] == "ok"
-        # remaining = 850 - 250 core = 600; weight_used = 1000 - 600 = 400
-        assert result["weight_used"] == 400.0
-        mock_spoolman_client.update_spool_full.assert_called_once_with(spool_id=42, remaining_weight=600.0)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_spool_returns_404_on_not_found(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH returns 404 when update_spool_full raises SpoolmanNotFoundError (I2)."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.update_spool_full.side_effect = SpoolmanNotFoundError("gone")
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "x"})
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_spool_returns_503_on_unavailable(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH returns 503 when update_spool_full raises SpoolmanUnavailableError (I2)."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        mock_spoolman_client.update_spool_full.side_effect = SpoolmanUnavailableError("down")
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "x"})
-        assert response.status_code == 503
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_sync_weight_returns_404_on_not_found(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH /weight returns 404 when update_spool_full raises SpoolmanNotFoundError (I2)."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.update_spool_full.side_effect = SpoolmanNotFoundError("gone")
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 500.0})
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_sync_weight_returns_503_on_unavailable(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH /weight returns 503 when update_spool_full raises SpoolmanUnavailableError (I2)."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        mock_spoolman_client.update_spool_full.side_effect = SpoolmanUnavailableError("down")
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 500.0})
-        assert response.status_code == 503
-
-
-class TestSpoolmanInventorySlicerFilament:
-    """slicer_filament persistence via Spoolman extra dict.
-
-    Spoolman has no native slicer_filament field — Bambuddy persists the
-    BambuStudio preset under bambu_slicer_filament[_name] keys in the
-    spool's extra dict and unwraps them in _map_spoolman_spool. Without
-    this round-trip the user's slicer-preset selection on the spool form
-    is silently dropped (#1114).
-    """
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_persists_slicer_filament_to_extra(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH with slicer_filament writes bambu_slicer_filament to extra.
-
-        Spoolman's PATCH MERGES extra keys, so we send via merge_spool_extra
-        not update_spool_full. Values are JSON-encoded strings.
-        """
-        import json as _json
-
-        mock_spoolman_client.ensure_extra_field = AsyncMock(return_value=True)
-
-        response = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={
-                "slicer_filament": "PFUSf543b298f8ea66",
-                "slicer_filament_name": "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)",
-            },
-        )
-        assert response.status_code == 200
-        # Field registration is idempotent — must be called for each key
-        ensure_calls = [c.args[0] for c in mock_spoolman_client.ensure_extra_field.call_args_list]
-        assert "bambu_slicer_filament" in ensure_calls
-        assert "bambu_slicer_filament_name" in ensure_calls
-        # Values must be JSON-encoded so read-side can json.loads + .strip('"')
-        mock_spoolman_client.merge_spool_extra.assert_called_once_with(
-            42,
-            {
-                "bambu_slicer_filament": _json.dumps("PFUSf543b298f8ea66"),
-                "bambu_slicer_filament_name": _json.dumps("Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"),
-            },
-        )
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_without_slicer_filament_skips_merge(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH without slicer_filament fields must not call merge_spool_extra.
-
-        Avoids overwriting an existing preset with empty/null when the user
-        just changed an unrelated field (e.g. note, weight).
-        """
-        response = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={"note": "just changing the note"},
-        )
-        assert response.status_code == 200
-        mock_spoolman_client.merge_spool_extra.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_clears_slicer_filament_with_empty_string(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Empty-string slicer_filament writes the JSON-encoded "" sentinel.
-
-        The read-side strip('"') resolves it to an empty string and falls
-        back to filament.name — matches the user-facing "clear preset" flow.
-        """
-        import json as _json
-
-        mock_spoolman_client.ensure_extra_field = AsyncMock(return_value=True)
-
-        response = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={"slicer_filament": "", "slicer_filament_name": ""},
-        )
-        assert response.status_code == 200
-        mock_spoolman_client.merge_spool_extra.assert_called_once_with(
-            42,
-            {
-                "bambu_slicer_filament": _json.dumps(""),
-                "bambu_slicer_filament_name": _json.dumps(""),
-            },
-        )
-
-
-class TestSpoolmanInventoryCostPerKg:
-    """Tests for the two-step cost_per_kg create path (PT-C2)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_spool_with_cost_per_kg_calls_price_update(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """POST with cost_per_kg calls update_spool_full with price= after creation."""
-        from unittest.mock import AsyncMock
-
-        mock_spoolman_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-
-        payload = {
-            "material": "PLA",
-            "brand": "Bambu Lab",
-            "label_weight": 1000,
-            "cost_per_kg": 24.99,
-        }
-        resp = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert resp.status_code == 200
-        # update_spool_full must have been called with price=24.99
-        calls = [
-            c
-            for c in mock_spoolman_client.update_spool_full.call_args_list
-            if c.kwargs.get("price") == 24.99 or (c.args and 24.99 in c.args)
-        ]
-        assert len(calls) >= 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_spool_without_cost_per_kg_skips_price_update(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """POST without cost_per_kg does not call update_spool_full."""
-        from unittest.mock import AsyncMock
-
-        mock_spoolman_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-
-        payload = {"material": "PLA", "brand": "Bambu Lab", "label_weight": 1000}
-        resp = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert resp.status_code == 200
-        mock_spoolman_client.update_spool_full.assert_not_called()
-
-
-class TestSpoolmanInventoryInputValidation:
-    """Tests for input validation added as security hardening."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_rejects_material_too_long(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """material longer than 64 chars is rejected with 422."""
-        payload = {"material": "A" * 65, "label_weight": 1000, "weight_used": 0}
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_rejects_note_too_long(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """note longer than 1000 chars is rejected with 422."""
-        payload = {
-            "material": "PLA",
-            "label_weight": 1000,
-            "weight_used": 0,
-            "note": "x" * 1001,
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_rejects_negative_weight_used(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Negative weight_used is rejected with 422."""
-        payload = {"material": "PLA", "label_weight": 1000, "weight_used": -1.0}
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_rejects_zero_label_weight(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """label_weight of 0 is rejected (minimum is 1)."""
-        payload = {"material": "PLA", "label_weight": 0, "weight_used": 0}
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_rejects_invalid_rgba(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Non-hex rgba string is rejected with 422."""
-        payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "GGGGGGFF"}
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_accepts_valid_6char_rgba(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """A valid 6-char hex rgba is accepted."""
-        payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "FF0000"}
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 200
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_weight_update_rejects_negative_grams(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Negative weight_grams on weight sync endpoint is rejected with 422."""
-        response = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/weight",
-            json={"weight_grams": -50.0},
-        )
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_rejects_tag_uid_too_long(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """tag_uid longer than 30 chars is rejected with 422 (NFC UID max 10 bytes = 20 hex chars, capped at 30)."""
-        payload = {"tag_uid": "A" * 65}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_rejects_tray_uuid_too_long(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """tray_uuid longer than 32 chars is rejected with 422."""
-        payload = {"tray_uuid": "B" * 65}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize("uuid_len", [16, 31])
-    async def test_update_rejects_tray_uuid_too_short(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        uuid_len: int,
-    ):
-        """tray_uuid shorter than 32 chars is rejected (min_length=max_length=32)."""
-        payload = {"tray_uuid": "A" * uuid_len}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_rejects_rgba_nine_chars(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """rgba must be max 8 hex chars; 9-char value is rejected with 422."""
-        payload = {"rgba": "FF0000FFA"}  # 9 chars
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_uid_below_min_length_rejected(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """tag_uid shorter than 8 hex chars is rejected with 422 (PT-I5)."""
-        payload = {"tag_uid": "AABBCC"}  # 6 chars, below min_length=8
-        resp = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_invalid_spoolman_url_scheme_returns_400(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        mock_spoolman_client,
-    ):
-        """A spoolman_url with a non-http(s) scheme is rejected."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value="ftp://evil.internal/"))
-        await db_session.commit()
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert response.status_code == 400
-        assert "http" in response.json()["detail"].lower()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "evil_url",
-        [
-            "file:///etc/passwd",
-            "gopher://127.0.0.1:70/",
-            "dict://internal.corp/",
-            "javascript:alert(1)",
-            "http://169.254.169.254/latest/meta-data/",  # AWS IMDS
-            "http://100.100.100.200/",  # Alibaba Cloud metadata
-            "http://[fd00:ec2::254]/",  # AWS IMDS IPv6
-            "http://0.0.0.0/",  # unspecified
-            "http://224.0.0.1/",  # IPv4 multicast
-            "http://[ff02::1]/",  # IPv6 multicast
-            "http://[::ffff:169.254.169.254]/",  # IPv4-mapped IPv6 IMDS bypass
-            "http://2130706433/",  # decimal-encoded 127.0.0.1
-            "http://0x7f000001/",  # hex-encoded 127.0.0.1
-        ],
-    )
-    async def test_ssrf_blocked_schemes_and_addresses(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        mock_spoolman_client,
-        evil_url: str,
-    ):
-        """SSRF: dangerous schemes, cloud metadata IPs, multicast, unspecified,
-        and numeric-encoded IPs must be rejected with 400. Loopback and
-        RFC-1918 private ranges are allowed — they are legitimate Spoolman
-        topologies for self-hosted Bambuddy deployments."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value=evil_url))
-        await db_session.commit()
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert response.status_code == 400, (
-            f"Expected 400 for SSRF URL {evil_url!r} but got {response.status_code}: {response.json()}"
-        )
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "lan_url",
-        [
-            "http://127.0.0.1:7912/",  # loopback
-            "http://[::1]:7912/",  # IPv6 loopback
-            "http://192.168.1.50:7912/",  # RFC-1918 /16
-            "http://10.0.0.5:7912/",  # RFC-1918 /8
-            "http://172.20.0.3:7912/",  # RFC-1918 /12
-        ],
-    )
-    async def test_ssrf_allows_lan_spoolman_topologies(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        mock_spoolman_client,
-        lan_url: str,
-    ):
-        """Regression: Bambuddy's normal deployment is LAN-local Spoolman.
-        Loopback and RFC-1918 private addresses must NOT be rejected as SSRF."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value=lan_url))
-        await db_session.commit()
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert response.status_code != 400, f"LAN URL {lan_url!r} was incorrectly blocked as SSRF: {response.json()}"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_rejects_storage_location_too_long(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """storage_location longer than 255 chars is rejected with 422."""
-        payload = {
-            "material": "PLA",
-            "label_weight": 1000,
-            "weight_used": 0,
-            "storage_location": "x" * 256,
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_rejects_storage_location_too_long(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """storage_location longer than 255 chars on PATCH is rejected with 422."""
-        payload = {"storage_location": "y" * 256}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 422
-
-
-class TestStorageLocationPassthrough:
-    """Tests that storage_location is correctly passed to and from Spoolman."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_spools_maps_spoolman_location_to_storage_location(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Spoolman's location field is exposed as storage_location in the response."""
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        spool = response.json()[0]
-        assert spool["storage_location"] == "Printer1 - AMS A1"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_spools_null_location_gives_null_storage_location(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """A Spoolman spool with no location gives null storage_location."""
-        spool_no_loc = {**SAMPLE_SPOOLMAN_SPOOL, "location": None}
-        mock_spoolman_client.get_all_spools.return_value = [spool_no_loc]
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        spool = response.json()[0]
-        assert spool["storage_location"] is None
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_passes_storage_location_to_spoolman(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """storage_location is forwarded as location when creating a Spoolman spool."""
-        payload = {
-            "material": "PLA",
-            "label_weight": 1000,
-            "weight_used": 0,
-            "storage_location": "Shelf B",
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 200
-        mock_spoolman_client.create_spool.assert_called_once()
-        _, kwargs = mock_spoolman_client.create_spool.call_args
-        assert kwargs.get("location") == "Shelf B"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_passes_storage_location_to_spoolman(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """storage_location is forwarded as location when updating a Spoolman spool."""
-        payload = {"storage_location": "Drawer 3"}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 200
-        mock_spoolman_client.update_spool_full.assert_called_once()
-        _, kwargs = mock_spoolman_client.update_spool_full.call_args
-        assert kwargs.get("location") == "Drawer 3"
-        assert kwargs.get("clear_location") is False
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_clears_storage_location_when_null_sent(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Explicitly sending null storage_location clears the Spoolman location."""
-        payload = {"storage_location": None}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 200
-        _, kwargs = mock_spoolman_client.update_spool_full.call_args
-        assert kwargs.get("clear_location") is True
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_clears_storage_location_when_empty_string_sent(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """Sending an empty string for storage_location also clears the Spoolman location."""
-        payload = {"storage_location": ""}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 200
-        _, kwargs = mock_spoolman_client.update_spool_full.call_args
-        assert kwargs.get("clear_location") is True
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_omitting_storage_location_does_not_write_location_to_spoolman(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH without storage_location in the payload must not touch Spoolman's location field.
-
-        Regression test for the round-trip bug: opening the edit modal and saving without
-        changing the location would previously echo the current Spoolman value back
-        (storage_location_changed=False branch used current.get("location") instead of None).
-        """
-        # Payload deliberately omits storage_location — simulates saving the modal
-        # without touching that field.
-        payload = {"note": "just updating the note"}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 200
-        mock_spoolman_client.update_spool_full.assert_called_once()
-        _, kwargs = mock_spoolman_client.update_spool_full.call_args
-        # location must be None so update_spool_full skips writing the field entirely
-        assert kwargs.get("location") is None
-        # clear_location must also be False — we are not explicitly clearing it either
-        assert kwargs.get("clear_location") is False
-
-
-class TestColorNamePassthrough:
-    """color_name is forwarded to find_or_create_filament on create and update (B6 / T5)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_passes_color_name_to_filament(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """color_name from the create payload is forwarded to find_or_create_filament."""
-        payload = {
-            "material": "PLA",
-            "label_weight": 1000,
-            "weight_used": 0,
-            "color_name": "Bambu Green",
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 200
-        mock_spoolman_client.find_or_create_filament.assert_called_once()
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        assert kwargs.get("color_name") == "Bambu Green"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_passes_color_name_to_filament(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """color_name from the update payload is forwarded to find_or_create_filament."""
-        payload = {"color_name": "Jade White"}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 200
-        mock_spoolman_client.find_or_create_filament.assert_called_once()
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        assert kwargs.get("color_name") == "Jade White"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_omits_color_name_when_not_provided(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """When color_name is not in the PATCH payload, the existing filament color_name is used."""
-        payload = {"note": "no color_name here"}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == 200
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        # color_name falls back to current filament's color_name (which is None in test fixture)
-        assert kwargs.get("color_name") is None
-
-
-class TestSpoolmanInventoryAuth:
-    """Write/delete endpoints require INVENTORY_UPDATE when auth is enabled."""
-
-    @pytest.fixture
-    async def auth_and_spoolman_settings(self, db_session):
-        """Enable both Spoolman and auth."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-        db_session.add(Settings(key="auth_enabled", value="true"))
-        await db_session.commit()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "method,path,payload",
-        [
-            ("POST", "/api/v1/spoolman/inventory/spools", {"material": "PLA", "label_weight": 1000, "weight_used": 0}),
-            (
-                "POST",
-                "/api/v1/spoolman/inventory/spools/bulk",
-                {"spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, "quantity": 1},
-            ),
-            ("PATCH", "/api/v1/spoolman/inventory/spools/42", {"note": "x"}),
-            ("DELETE", "/api/v1/spoolman/inventory/spools/42", None),
-            ("POST", "/api/v1/spoolman/inventory/spools/42/archive", None),
-            ("POST", "/api/v1/spoolman/inventory/spools/42/restore", None),
-            ("PATCH", "/api/v1/spoolman/inventory/spools/42/weight", {"weight_grams": 100.0}),
-        ],
-    )
-    async def test_write_endpoints_require_auth(
-        self,
-        async_client: AsyncClient,
-        auth_and_spoolman_settings,
-        method: str,
-        path: str,
-        payload: dict | None,
-    ):
-        """All write/delete endpoints return 401 when auth is enabled and no token is provided."""
-        response = await async_client.request(method, path, json=payload)
-        assert response.status_code == 401, (
-            f"{method} {path} should require auth but got {response.status_code}: {response.json()}"
-        )
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "method,path",
-        [
-            ("GET", "/api/v1/spoolman/inventory/spools"),
-            ("GET", "/api/v1/spoolman/inventory/spools/42"),
-        ],
-    )
-    async def test_read_endpoints_require_auth(
-        self,
-        async_client: AsyncClient,
-        auth_and_spoolman_settings,
-        method: str,
-        path: str,
-    ):
-        """Read endpoints also require auth when auth is enabled."""
-        response = await async_client.request(method, path)
-        assert response.status_code == 401, (
-            f"{method} {path} should require auth but got {response.status_code}: {response.json()}"
-        )
-
-    @pytest.fixture
-    async def viewer_token(self, db_session):
-        """Create a Viewer-group user (INVENTORY_READ only, no INVENTORY_UPDATE)."""
-        from sqlalchemy import select
-
-        from backend.app.core.auth import create_access_token, get_password_hash
-        from backend.app.models.group import Group
-        from backend.app.models.settings import Settings
-        from backend.app.models.user import User
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-        db_session.add(Settings(key="auth_enabled", value="true"))
-        await db_session.commit()
-
-        viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
-        viewer = User(
-            username="sm_inv_viewer",
-            password_hash=get_password_hash("pw"),
-            is_active=True,
-        )
-        viewer.groups.append(viewer_group)
-        db_session.add(viewer)
-        await db_session.commit()
-        return create_access_token(data={"sub": viewer.username})
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "method,path,payload",
-        [
-            ("POST", "/api/v1/spoolman/inventory/spools", {"material": "PLA", "label_weight": 1000, "weight_used": 0}),
-            (
-                "POST",
-                "/api/v1/spoolman/inventory/spools/bulk",
-                {"spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, "quantity": 1},
-            ),
-            ("PATCH", "/api/v1/spoolman/inventory/spools/42", {"note": "x"}),
-            ("DELETE", "/api/v1/spoolman/inventory/spools/42", None),
-            ("POST", "/api/v1/spoolman/inventory/spools/42/archive", None),
-            ("POST", "/api/v1/spoolman/inventory/spools/42/restore", None),
-            ("PATCH", "/api/v1/spoolman/inventory/spools/42/weight", {"weight_grams": 100.0}),
-        ],
-    )
-    async def test_write_endpoints_return_403_for_viewer(
-        self,
-        async_client: AsyncClient,
-        viewer_token,
-        method: str,
-        path: str,
-        payload: dict | None,
-    ):
-        """Viewer-group users (INVENTORY_READ, no INVENTORY_UPDATE) get 403 on write endpoints."""
-        response = await async_client.request(
-            method,
-            path,
-            json=payload,
-            headers={"Authorization": f"Bearer {viewer_token}"},
-        )
-        assert response.status_code == 403, (
-            f"{method} {path} should return 403 for read-only user but got {response.status_code}: {response.json()}"
-        )
-        # Error body must mention the permission string so a "banned-user middleware"
-        # regression (generic 403 with no permission context) doesn't pass silently.
-        detail = response.json().get("detail", "")
-        assert "inventory:update" in detail, f"Expected 'inventory:update' in 403 detail but got: {detail!r}"
-
-
-# ---------------------------------------------------------------------------
-# Additional regression tests for second-round review items
-# ---------------------------------------------------------------------------
-
-
-class TestSpoolmanInventorySecurityExtras:
-    """Additional security/validation tests added in second review round."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_rejects_double_hash_rgba(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """SEC-3: rgba like '##FF0000' (double hash) must be rejected with 422."""
-        payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "##FF0000"}
-        response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize("spool_id", [0, -1])
-    async def test_path_param_non_positive_spool_id_returns_422(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        spool_id: int,
-    ):
-        """SEC-5: /spools/0 and /spools/-1 must be rejected with 422 (Path gt=0)."""
-        response = await async_client.get(f"/api/v1/spoolman/inventory/spools/{spool_id}")
-        assert response.status_code == 422, f"Expected 422 for spool_id={spool_id} but got {response.status_code}"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "tag_uid,expected_status",
-        [
-            # After B1 fix: non-null tag_uid on PATCH /spools/{id} is rejected (use /tag endpoint)
-            ("A" * 30, 422),  # non-null → 422 (use /tag endpoint instead)
-            ("DEADBEEF12345678", 422),  # non-null → 422 regardless of length
-            ("A" * 31, 422),  # exceeds max_length — also 422
-            ("A" * 32, 422),  # tray_uuid-length value — also 422
-        ],
-    )
-    async def test_tag_uid_length_boundary(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        tag_uid: str,
-        expected_status: int,
-    ):
-        """tag_uid on PATCH /spools/{id} — all non-null values are rejected (B1 fix; use /tag endpoint)."""
-        payload = {"tag_uid": tag_uid}
-        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
-        assert response.status_code == expected_status, (
-            f"tag_uid len={len(tag_uid)}: expected {expected_status} but got {response.status_code}"
-        )
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bulk_create_partial_failure_returns_207(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """I9: bulk create with quantity=3 where middle call fails → 207 Multi-Status."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        results = [SAMPLE_SPOOLMAN_SPOOL, SpoolmanUnavailableError("Spoolman down"), SAMPLE_SPOOLMAN_SPOOL]
-        mock_spoolman_client.create_spool.side_effect = results
-
-        payload = {
-            "spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0},
-            "quantity": 3,
-        }
-        response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
-        assert response.status_code == 207, (
-            f"Expected 207 Multi-Status for partial failure but got {response.status_code}"
-        )
-        body = response.json()
-        assert isinstance(body, dict)
-        assert body["requested_count"] == 3
-        assert body["failed_count"] == 1
-        assert len(body["created"]) == 2
-
-
-class TestTagClearPreservesExtraKeys:
-    """Regression test: clearing tag_uid must not wipe unrelated Spoolman extra fields."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_clear_preserves_custom_extra_key(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """PATCH tag_uid=None clears tag without dropping unrelated extra keys.
-
-        Spoolman PATCHes the extra dict by MERGING — popping a key from the
-        dict and sending the rest doesn't actually clear it. The endpoint
-        sets tag = json.dumps("") explicitly; read-side filters strip the
-        wrapping quotes and treat the empty string as "no tag" (#1114).
-        """
-        import json as _json
-
-        spool_with_extra = {
-            **SAMPLE_SPOOLMAN_SPOOL,
-            "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"', "custom_key": "keep_me"},
-        }
-        mock_spoolman_client.get_spool = AsyncMock(return_value=spool_with_extra)
-        mock_spoolman_client.update_spool_full = AsyncMock(return_value=spool_with_extra)
-
-        response = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={"tag_uid": None},
-        )
-        assert response.status_code == 200
-
-        mock_spoolman_client.update_spool_full.assert_called_once()
-        _, kwargs = mock_spoolman_client.update_spool_full.call_args
-        sent_extra = kwargs.get("extra")
-        assert sent_extra is not None, "extra must be sent when tag is cleared"
-        assert sent_extra.get("tag") == _json.dumps(""), (
-            "tag must be set to JSON empty-string sentinel (Spoolman PATCH merges; "
-            "popping the key would leave the previous value in place)"
-        )
-        assert sent_extra.get("custom_key") == "keep_me", "unrelated extra keys must survive"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_clear_refetches_spool_inside_lock(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """B7: tag-clear does a fresh get_spool() re-fetch inside the lock, not the stale one.
-
-        Simulates a write that changes extra between the initial get_spool (used for
-        other field resolution) and the lock acquisition.  The extra sent to
-        update_spool_full must come from the second (in-lock) fetch, not the first.
-        """
-        stale_extra = {"tag": '"AABBCCDD"', "custom_key": "stale_value"}
-        fresh_extra = {"tag": '"AABBCCDD"', "custom_key": "fresh_value"}
-
-        stale_spool = {**SAMPLE_SPOOLMAN_SPOOL, "extra": stale_extra}
-        fresh_spool = {**SAMPLE_SPOOLMAN_SPOOL, "extra": fresh_extra}
-
-        # First call returns stale; second call (inside lock) returns fresh
-        mock_spoolman_client.get_spool = AsyncMock(side_effect=[stale_spool, fresh_spool])
-        mock_spoolman_client.update_spool_full = AsyncMock(return_value=fresh_spool)
-
-        response = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={"tag_uid": None, "tray_uuid": None},
-        )
-        assert response.status_code == 200
-
-        # get_spool called twice: once for field resolution, once for fresh extra fetch
-        assert mock_spoolman_client.get_spool.call_count == 2
-
-        import json as _json
-
-        _, kwargs = mock_spoolman_client.update_spool_full.call_args
-        sent_extra = kwargs.get("extra")
-        assert sent_extra is not None
-        # Tag is set to the JSON empty-string sentinel (not popped) — Spoolman
-        # PATCH merges, so popping the key would leave the previous value.
-        assert sent_extra.get("tag") == _json.dumps("")
-        # custom_key must come from the fresh re-fetch, not the stale first fetch
-        assert sent_extra.get("custom_key") == "fresh_value"
-
-
-class TestSpoolmanInventorySSRFSpoolBuddyPath:
-    """SSRF tests for _get_spoolman_client_or_none (nfc/* and scale/ endpoints)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "evil_url",
-        [
-            "file:///etc/passwd",
-            "http://169.254.169.254/latest/meta-data/",  # AWS IMDS
-            "http://0.0.0.0/",  # unspecified
-            "http://[::ffff:169.254.169.254]/",  # IPv4-mapped IMDS bypass
-        ],
-    )
-    async def test_nfc_tag_scanned_with_ssrf_url_ignores_spoolman(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        evil_url: str,
-    ):
-        """SSRF: _get_spoolman_client_or_none silently disables Spoolman for unsafe URLs
-        on the SpoolBuddy NFC path (tag-scanned broadcasts unknown_tag, not 400)."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value=evil_url))
-        await db_session.commit()
-
-        from unittest.mock import AsyncMock, patch
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                "/api/v1/spoolbuddy/nfc/tag-scanned",
-                json={"device_id": "sb-ssrf", "tag_uid": "AABBCCDD"},
-            )
-
-        # Must not crash or proxy the SSRF URL — unknown_tag is the safe degraded response
-        assert resp.status_code == 200
-        if mock_ws.broadcast.called:
-            msg = mock_ws.broadcast.call_args[0][0]
-            assert msg["type"] == "spoolbuddy_unknown_tag"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "evil_url",
-        [
-            "http://169.254.169.254/latest/meta-data/",  # AWS IMDS
-            "http://[::ffff:169.254.169.254]/",  # IPv4-mapped IMDS bypass
-        ],
-    )
-    async def test_nfc_write_result_with_ssrf_url_degrades_gracefully(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        evil_url: str,
-    ):
-        """SSRF: write-result with unsafe Spoolman URL must not proxy to the evil host.
-
-        write-result calls Spoolman to write-back the tag UID when data_origin='spoolman'.
-        With an SSRF URL, _get_spoolman_client_or_none returns None so the call is skipped
-        and the route returns 502 (tag written but link not persisted — not a server crash).
-        """
-        import json as _json
-
-        from backend.app.models.settings import Settings
-        from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value=evil_url))
-        # Register the device so the route doesn't 404 before reaching the SSRF guard.
-        db_session.add(
-            SpoolBuddyDevice(
-                device_id="sb-ssrf-wr",
-                hostname="sb-ssrf-wr.local",
-                ip_address="127.0.0.1",
-                pending_command="write_tag",
-                pending_write_payload=_json.dumps({"spool_id": 99, "ndef_data_hex": "DEAD", "data_origin": "spoolman"}),
-            )
-        )
-        await db_session.commit()
-
-        from unittest.mock import AsyncMock, patch
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                "/api/v1/spoolbuddy/nfc/write-result",
-                json={
-                    "device_id": "sb-ssrf-wr",
-                    "spool_id": 99,
-                    "tag_uid": "AABBCCDD",
-                    "success": True,
-                },
-            )
-
-        # 502 = tag written to NFC but Spoolman link not persisted (SSRF guard blocked it).
-        # Must not be 500 (crash) and must not have proxied to the evil host.
-        assert resp.status_code == 502
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "evil_url",
-        [
-            "http://169.254.169.254/latest/meta-data/",  # AWS IMDS
-        ],
-    )
-    async def test_scale_update_weight_with_ssrf_url_degrades_gracefully(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        evil_url: str,
-    ):
-        """SSRF: scale weight update with unsafe Spoolman URL must not proxy to the evil host."""
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value=evil_url))
-        await db_session.commit()
-
-        from unittest.mock import AsyncMock, patch
-
-        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
-            mock_ws.broadcast = AsyncMock()
-            resp = await async_client.post(
-                "/api/v1/spoolbuddy/scale/update-spool-weight",
-                json={"device_id": "sb-ssrf-scale", "spool_id": 1, "weight_grams": 500.0},
-            )
-
-        # Must not crash or proxy to an SSRF host
-        assert resp.status_code in (200, 404, 422)
-
-
-class TestMergeSpoolExtraPreservesKeys:
-    """Unit-level test for merge_spool_extra key preservation (via mocked Spoolman)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_merge_preserves_unrelated_extra_keys(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-    ):
-        """merge_spool_extra must deep-merge rather than overwrite the extra dict.
-
-        Seed extra={"custom_key": "keep_me", "tag": "old"}.
-        After merging {"tag": "new"}, the PATCH payload must still contain custom_key.
-        """
-        from unittest.mock import AsyncMock, patch
-
-        existing_spool = {
-            **SAMPLE_SPOOLMAN_SPOOL,
-            "extra": {"custom_key": "keep_me", "tag": '"old"'},
-        }
-        updated_spool = {**existing_spool, "extra": {"custom_key": "keep_me", "tag": '"new"'}}
-
-        mock_client = mock_spoolman_client
-        mock_client.get_spool = AsyncMock(return_value=existing_spool)
-        mock_client.update_spool_full = AsyncMock(return_value=updated_spool)
-
-        # Call merge_spool_extra directly through the service
-        from backend.app.services.spoolman import SpoolmanClient
-
-        client = SpoolmanClient.__new__(SpoolmanClient)
-        client.base_url = "http://localhost:7912"
-        client.api_url = "http://localhost:7912/api/v1"
-        client._extra_locks = {}
-
-        async def _mock_get(spool_id):
-            return existing_spool
-
-        async def _mock_update(spool_id, **kwargs):
-            # Capture what was actually sent
-            _mock_update.captured_extra = kwargs.get("extra")
-            return updated_spool
-
-        _mock_update.captured_extra = None
-        client.get_spool = _mock_get
-        client.update_spool_full = _mock_update
-
-        result = await client.merge_spool_extra(42, {"tag": '"new"'})
-
-        # The merged extra must include the unrelated key
-        assert _mock_update.captured_extra is not None
-        assert _mock_update.captured_extra.get("custom_key") == "keep_me"
-        assert _mock_update.captured_extra.get("tag") == '"new"'
-        assert result is not None
-
-
-class TestGetClientValueError:
-    """Test the ValueError branch in _get_client when init_spoolman_client fails (Gap 5)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_returns_400_when_init_spoolman_client_raises_value_error(
-        self, async_client: AsyncClient, spoolman_settings
-    ):
-        """If init_spoolman_client raises ValueError after SSRF check passes, return HTTP 400."""
-        with (
-            patch(
-                "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
-                AsyncMock(return_value=None),
-            ),
-            patch(
-                "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
-                AsyncMock(side_effect=ValueError("unsupported scheme")),
-            ),
-        ):
-            resp = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert resp.status_code == 400
-        assert "unsupported scheme" in resp.json()["detail"]
-
-
-class TestBulkCreateWithPriceFailure:
-    """Test that bulk create handles price-update failures per C1/C8 semantics."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bulk_create_price_503_moves_spool_to_failures(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """When price update fails (503), the spool goes to failures — overall returns 207 if at least one succeeds."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        # First price update fails (SpoolmanUnavailableError → 503), second succeeds
-        mock_spoolman_client.update_spool_full = AsyncMock(
-            side_effect=[SpoolmanUnavailableError("price server down"), SAMPLE_SPOOLMAN_SPOOL]
-        )
-        mock_spoolman_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-
-        payload = {
-            "spool": {
-                "material": "PLA",
-                "brand": "Bambu Lab",
-                "label_weight": 1000,
-                "cost_per_kg": 19.99,
-            },
-            "quantity": 2,
-        }
-        resp = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
-        # One spool succeeded, one failed (price 503) → 207 Partial
-        assert resp.status_code == 207
-        data = resp.json()
-        assert len(data["created"]) == 1
-        assert data["failed_count"] == 1
-        # Both Spoolman creates were attempted
-        assert mock_spoolman_client.create_spool.call_count == 2
-        # Both price updates were attempted
-        assert mock_spoolman_client.update_spool_full.call_count == 2
-
-
-class TestSpoolTagLinkValidation:
-    """NEW-B1: /spools/{id}/tag endpoint validates tag_uid length and content."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_uid_6_chars_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
-        """tag_uid with 6 hex chars is rejected — minimum is 8 chars (4-byte UID)."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/tag",
-            json={"tag_uid": "AABBCC"},  # 6 chars — below new minimum
-        )
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_uid_all_zeros_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
-        """tag_uid that is all-zero bytes is rejected as an unwritten/blank tag."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/tag",
-            json={"tag_uid": "00000000000000"},  # 14 zeros
-        )
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_uid_valid_14_chars_accepted(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """tag_uid with 14 valid hex chars (7-byte UID) is accepted."""
-        # This tag is not in SAMPLE_SPOOLMAN_SPOOL so no duplicate conflict.
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/tag",
-            json={"tag_uid": "AABBCCDD112233"},  # 14 chars, valid, not all-zeros
-        )
-        assert resp.status_code == 200
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_uid_8_chars_accepted(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
-        """tag_uid with 8 hex chars (4-byte Bambu Lab NFC UID) is accepted after min_length fix."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/tag",
-            json={"tag_uid": "2728C17B"},  # 8 chars — real Bambu Lab 4-byte hardware UID
-        )
-        assert resp.status_code == 200
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_tag_uid_8_zeros_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
-        """tag_uid with 8 zero chars is rejected — all-zeros validator applies at the new minimum."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/tag",
-            json={"tag_uid": "00000000"},  # 8 zeros — meets min_length but is a blank/unwritten tag
-        )
-        assert resp.status_code == 422
-
-
-class TestLinkTagDuplicate:
-    """NEW-I1: /spools/{id}/tag returns 409 when another spool already has the same tag."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_tag_returns_200_when_tag_not_on_another_spool(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """Linking a fresh tag to spool 42 returns 200 — no duplicate in Spoolman."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/tag",
-            json={"tag_uid": "AABBCCDD112233"},  # not in SAMPLE_SPOOLMAN_SPOOL
-        )
-        assert resp.status_code == 200
-        mock_spoolman_client.update_spool_full.assert_called_once()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_link_tag_returns_409_when_same_tag_on_different_spool(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """Linking spool 99 to a tag that spool 42 already carries must return 409."""
-        # SAMPLE_SPOOLMAN_SPOOL (id=42) has extra.tag = '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'.
-        # Attempting to assign the same tag to spool 99 must be rejected.
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/99/tag",
-            json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},  # 32-char tray UUID
-        )
-        assert resp.status_code == 409
-        detail = resp.json()["detail"]
-        assert "42" in str(detail)
-
-
-class TestSpoolmanInventoryUpdateCoreWeight:
-    """core_weight is accepted for schema parity but not persisted — any value should be accepted."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_patch_core_weight_other_than_250_accepted(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """PATCH with core_weight != 250 is accepted (field is ignored server-side, not rejected)."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={"core_weight": 100},
-        )
-        assert resp.status_code == 200
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_patch_core_weight_250_explicitly_is_accepted(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """PATCH with core_weight=250 (the default) is valid and returns 200."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={"core_weight": 250},
-        )
-        assert resp.status_code == 200
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_patch_without_core_weight_is_accepted(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """PATCH without core_weight (omitted) must not trigger the validator — returns 200."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={"note": "no core_weight key"},
-        )
-        assert resp.status_code == 200
-
-
-class TestUnlinkSpool:
-    """POST /spoolman/spools/{id}/unlink clears Spoolman tag without re-entrant lock deadlock.
-
-    Spoolman PATCHes the extra dict by MERGING — popping a key + sending the
-    rest doesn't clear the popped key. The endpoint sends the JSON empty-string
-    sentinel ('""') which the read-side filters strip. (#1114)
-
-    The endpoint uses merge_spool_extra (not update_spool_full directly)
-    because (a) merge_spool_extra owns the per-spool extra_lock for atomic
-    read-modify-write semantics, and (b) wrapping it in another extra_lock
-    would deadlock — asyncio.Lock is not re-entrant.
-    """
-
-    @pytest.fixture
-    def mock_unlink_client(self):
-        """Mock Spoolman client for the spoolman.py (non-inventory) route."""
-        spool_with_tag = {
-            **SAMPLE_SPOOLMAN_SPOOL,
-            "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"', "custom": "keep"},
-        }
-        mock_client = MagicMock()
-        mock_client.base_url = "http://localhost:7912"
-        mock_client.health_check = AsyncMock(return_value=True)
-        mock_client.get_spool = AsyncMock(return_value=spool_with_tag)
-        # merge_spool_extra returns the spool with the tag cleared (and custom
-        # preserved) — that's what the read-side will see after the fix.
-        mock_client.merge_spool_extra = AsyncMock(
-            return_value={**spool_with_tag, "extra": {"tag": '""', "custom": "keep"}}
-        )
-
-        with (
-            patch(
-                "backend.app.api.routes.spoolman.get_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-            patch(
-                "backend.app.api.routes.spoolman.init_spoolman_client",
-                AsyncMock(return_value=mock_client),
-            ),
-        ):
-            yield mock_client
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unlink_sets_tag_to_json_empty_string(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_unlink_client,
-    ):
-        """Unlink calls merge_spool_extra with the JSON-empty-string sentinel.
-
-        Pre-fix the endpoint did `cur_extra.pop("tag")` then PATCHed the rest.
-        Spoolman silently kept the previous tag because the key wasn't in the
-        payload (PATCH merges). Now the endpoint sends `{"tag": '""'}` and
-        the read-side .strip('"') resolves it to "" → spool drops out of
-        get_linked_spools.
-        """
-        import json as _json
-
-        resp = await async_client.post("/api/v1/spoolman/spools/42/unlink")
-        assert resp.status_code == 200
-
-        mock_unlink_client.merge_spool_extra.assert_called_once_with(42, {"tag": _json.dumps("")})
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unlink_preserves_other_extra_keys(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_unlink_client,
-    ):
-        """Unrelated extra keys must survive unlink.
-
-        merge_spool_extra is responsible for the merge (read current → merge
-        new fields → PATCH). The unlink endpoint only sends `{"tag": ...}`,
-        so any other extra key on the spool is automatically preserved by
-        merge_spool_extra's read-merge-write semantics.
-        """
-        resp = await async_client.post("/api/v1/spoolman/spools/42/unlink")
-        assert resp.status_code == 200
-
-        # The endpoint passes only the tag key — merge_spool_extra does the
-        # rest. We don't assert anything about `custom` on the call args
-        # because the route doesn't see / pass it.
-        _, args, _ = mock_unlink_client.merge_spool_extra.mock_calls[0]
-        sent_fields = args[1] if len(args) >= 2 else {}
-        assert sent_fields == {"tag": '""'}, "unlink should only send the tag key — merge_spool_extra does the merge"
-
-
-# ---------------------------------------------------------------------------
-# B1: GET /spoolman/inventory/filaments
-# B2: POST /spools with spoolman_filament_id bypasses find_or_create_filament
-# ---------------------------------------------------------------------------
-
-SAMPLE_FILAMENT_DICT = {
-    "id": 7,
-    "name": "PLA Basic",
-    "material": "PLA",
-    "color_hex": "FF0000",
-    "color_name": "Red",
-    "weight": 1000,
-    "spool_weight": 196,
-    "vendor": {"id": 3, "name": "Bambu Lab"},
-}
-
-
-class TestListSpoolmanFilaments:
-    """Tests for GET /api/v1/spoolman/inventory/filaments (B1)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_filaments_disabled_returns_400(self, async_client: AsyncClient):
-        """Without Spoolman enabled the endpoint returns 400."""
-        resp = await async_client.get("/api/v1/spoolman/inventory/filaments")
-        assert resp.status_code == 400
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_filaments_unreachable_returns_503(self, async_client: AsyncClient, spoolman_settings):
-        """503 is returned when _get_client raises HTTPException(503)."""
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(side_effect=HTTPException(status_code=503, detail="Spoolman server is not reachable")),
-        ):
-            resp = await async_client.get("/api/v1/spoolman/inventory/filaments")
-        assert resp.status_code == 503
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_filaments_success(self, async_client: AsyncClient, spoolman_settings):
-        """Success path returns normalised filament list including spool_weight."""
-        mock_client = MagicMock()
-        mock_client.get_filaments = AsyncMock(return_value=[SAMPLE_FILAMENT_DICT])
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.get("/api/v1/spoolman/inventory/filaments")
-
-        assert resp.status_code == 200
-        data = resp.json()
-        assert isinstance(data, list)
-        assert len(data) == 1
-        entry = data[0]
-        assert entry["id"] == 7
-        assert entry["material"] == "PLA"
-        assert entry["spool_weight"] == 196
-        assert entry["vendor"]["name"] == "Bambu Lab"
-
-
-class TestCreateSpoolWithFilamentId:
-    """Tests for POST /api/v1/spoolman/inventory/spools with spoolman_filament_id (B2)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_with_filament_id_skips_find_or_create(self, async_client: AsyncClient, spoolman_settings):
-        """When spoolman_filament_id is provided, find_or_create_filament must NOT be called."""
-        mock_client = MagicMock()
-        mock_client.find_or_create_filament = AsyncMock(return_value=7)
-        mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-        mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/spools",
-                json={"spoolman_filament_id": 7},
-            )
-
-        assert resp.status_code == 200
-        mock_client.find_or_create_filament.assert_not_called()
-        mock_client.create_spool.assert_called_once()
-        _, kwargs = mock_client.create_spool.call_args
-        assert kwargs.get("filament_id") == 7
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_with_invalid_filament_id_returns_404(self, async_client: AsyncClient, spoolman_settings):
-        """An invalid spoolman_filament_id (not in Spoolman) must return 404."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_client = MagicMock()
-        mock_client.create_spool = AsyncMock(side_effect=SpoolmanNotFoundError("filament not found"))
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/spools",
-                json={"spoolman_filament_id": 9999},
-            )
-
-        assert resp.status_code == 404
-        assert "9999" in resp.json()["detail"]
-
-
-# ---------------------------------------------------------------------------
-# WICHTIG-12: Additional edge-case tests
-# ---------------------------------------------------------------------------
-
-
-class TestBulkCreateWithFilamentId:
-    """Bulk create with spoolman_filament_id skips find_or_create_filament."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bulk_create_with_filament_id_skips_find_or_create(
-        self, async_client: AsyncClient, spoolman_settings
-    ):
-        """Bulk POST with spoolman_filament_id must NOT call find_or_create_filament."""
-        mock_client = MagicMock()
-        mock_client.find_or_create_filament = AsyncMock(return_value=7)
-        mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-        mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
-        with patch(
-            "backend.app.api.routes.spoolman_inventory._get_client",
-            AsyncMock(return_value=mock_client),
-        ):
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/spools/bulk",
-                json={"spool": {"spoolman_filament_id": 7}, "quantity": 2},
-            )
-
-        assert resp.status_code == 200
-        mock_client.find_or_create_filament.assert_not_called()
-        assert mock_client.create_spool.call_count == 2
-        for call in mock_client.create_spool.call_args_list:
-            _, kwargs = call
-            assert kwargs.get("filament_id") == 7
-
-
-class TestCreateSpoolValidation:
-    """Validation edge cases for SpoolmanInventoryCreate."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_spool_filament_id_zero_returns_422(self, async_client: AsyncClient, spoolman_settings):
-        """spoolman_filament_id=0 must fail Field(gt=0) validation → 422."""
-        resp = await async_client.post(
-            "/api/v1/spoolman/inventory/spools",
-            json={"spoolman_filament_id": 0},
-        )
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_spool_without_material_or_filament_id_returns_422(
-        self, async_client: AsyncClient, spoolman_settings
-    ):
-        """Neither material nor spoolman_filament_id → model_validator must reject → 422."""
-        resp = await async_client.post(
-            "/api/v1/spoolman/inventory/spools",
-            json={"label_weight": 1000},
-        )
-        assert resp.status_code == 422
-
-
-class TestNormalizeFilament:
-    """Unit-style tests for _normalize_filament helper (imported directly)."""
-
-    def test_normalize_filament_null_vendor(self):
-        from backend.app.api.routes.spoolman_inventory import _normalize_filament
-
-        result = _normalize_filament({"id": 5, "name": "PLA", "vendor": None})
-        assert result is not None
-        assert result["vendor"] is None
-
-    def test_normalize_filament_null_id_returns_none(self):
-        from backend.app.api.routes.spoolman_inventory import _normalize_filament
-
-        result = _normalize_filament({"id": None, "name": "PLA"})
-        assert result is None
-
-    def test_normalize_filament_zero_id_returns_none(self):
-        from backend.app.api.routes.spoolman_inventory import _normalize_filament
-
-        result = _normalize_filament({"id": 0, "name": "PLA"})
-        assert result is None
-
-
-# ---------------------------------------------------------------------------
-# F1: TestTranslateSpoolmanErrors — 502/404/503 paths through _translate_spoolman_errors
-# ---------------------------------------------------------------------------
-
-
-class TestTranslateSpoolmanErrors:
-    """F1: _translate_spoolman_errors() maps Spoolman exceptions to HTTP codes."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_not_found_returns_404(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """SpoolmanNotFoundError from get_spool → 404."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_spoolman_client.get_spool.side_effect = SpoolmanNotFoundError("spool 999 not found")
-        resp = await async_client.get("/api/v1/spoolman/inventory/spools/999")
-        assert resp.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_unavailable_returns_503(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """SpoolmanUnavailableError from get_spool → 503."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        mock_spoolman_client.get_spool.side_effect = SpoolmanUnavailableError("network error")
-        resp = await async_client.get("/api/v1/spoolman/inventory/spools/42")
-        assert resp.status_code == 503
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_spoolman_client_error_returns_502_with_upstream_status(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """SpoolmanClientError from get_spool → 502 with upstream_status in body."""
-        from backend.app.services.spoolman import SpoolmanClientError
-
-        mock_spoolman_client.get_spool.side_effect = SpoolmanClientError("Spoolman rejected", 422, "filament not found")
-        resp = await async_client.get("/api/v1/spoolman/inventory/spools/42")
-        assert resp.status_code == 502
-        body = resp.json()
-        assert body["detail"]["upstream_status"] == 422
-        assert body["detail"]["upstream_body"] == "filament not found"
-
-
-# ---------------------------------------------------------------------------
-# F2: _get_client health_check returns False → 503
-# ---------------------------------------------------------------------------
-
-
-class TestGetClientHealthCheckFalse:
-    """F2: _get_client raises 503 when health_check() returns False."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_returns_503_when_health_check_returns_false(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """health_check() → False should produce 503 on any inventory call."""
-        import time
-
-        import backend.app.api.routes.spoolman_inventory as inv_module
-
-        mock_spoolman_client.health_check = AsyncMock(return_value=False)
-        # Clear the TTL cache so health_check is actually called
-        inv_module._health_check_cache.clear()
-        resp = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert resp.status_code == 503
-
-
-# ---------------------------------------------------------------------------
-# F3: SpoolTagLinkRequest both fields null → 422
-# ---------------------------------------------------------------------------
-
-
-class TestSpoolTagLinkBothNull:
-    """F3: /spools/{id}/tag with both tag_uid and tray_uuid null → 422."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_both_null_returns_422(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
-        """Sending {} (both fields absent) → at_least_one validator → 422."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/tag",
-            json={},
-        )
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_both_explicitly_null_returns_422(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """Sending {tag_uid: null, tray_uuid: null} → at_least_one validator → 422."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42/tag",
-            json={"tag_uid": None, "tray_uuid": None},
-        )
-        assert resp.status_code == 422
-
-
-# ---------------------------------------------------------------------------
-# F5: RBAC lists — missing endpoints
-# ---------------------------------------------------------------------------
-
-
-class TestSpoolmanInventoryAuthExtended:
-    """F5: Additional endpoints in RBAC auth/403 parametrize lists."""
-
-    @pytest.fixture
-    async def auth_and_spoolman_settings(self, db_session):
-        from backend.app.models.settings import Settings
-
-        db_session.add(Settings(key="spoolman_enabled", value="true"))
-        db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-        db_session.add(Settings(key="auth_enabled", value="true"))
-        await db_session.commit()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "method,path,payload",
-        [
-            ("PATCH", "/api/v1/spoolman/inventory/spools/42/tag", {"tag_uid": "AABBCCDDEE112233"}),
-            ("POST", "/api/v1/spoolman/inventory/sync-ams-weights", {"printer_id": 1, "ams_data": []}),
-            ("PATCH", "/api/v1/spoolman/inventory/filaments/7", {"spool_weight": 196.0}),
-        ],
-    )
-    async def test_extended_write_endpoints_require_auth(
-        self,
-        async_client: AsyncClient,
-        auth_and_spoolman_settings,
-        method: str,
-        path: str,
-        payload: dict | None,
-    ):
-        """Additional write endpoints return 401 when auth is enabled and no token is provided."""
-        resp = await async_client.request(method, path, json=payload)
-        assert resp.status_code == 401, f"{method} {path} should require auth but got {resp.status_code}: {resp.json()}"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    @pytest.mark.parametrize(
-        "method,path",
-        [
-            ("GET", "/api/v1/spoolman/inventory/filaments"),
-        ],
-    )
-    async def test_extended_read_endpoints_require_auth(
-        self,
-        async_client: AsyncClient,
-        auth_and_spoolman_settings,
-        method: str,
-        path: str,
-    ):
-        """Additional read endpoints return 401 when auth is enabled and no token is provided."""
-        resp = await async_client.request(method, path)
-        assert resp.status_code == 401, f"{method} {path} should require auth but got {resp.status_code}: {resp.json()}"
-
-
-# ---------------------------------------------------------------------------
-# F8: _normalize_filament negative ID returns None
-# ---------------------------------------------------------------------------
-
-
-class TestNormalizeFilamentNegativeId:
-    """F8: _normalize_filament with negative id → None (was only checking == 0)."""
-
-    def test_normalize_filament_negative_id_returns_none(self):
-        from backend.app.api.routes.spoolman_inventory import _normalize_filament
-
-        result = _normalize_filament({"id": -1, "name": "PLA"})
-        assert result is None
-
-    def test_normalize_filament_large_negative_id_returns_none(self):
-        from backend.app.api.routes.spoolman_inventory import _normalize_filament
-
-        result = _normalize_filament({"id": -999, "name": "PLA"})
-        assert result is None
-
-
-# ---------------------------------------------------------------------------
-# F9: weight_used > label_weight cross-field validator integration test
-# ---------------------------------------------------------------------------
-
-
-class TestCreateSpoolWeightValidation:
-    """F9: SpoolmanInventoryCreate.validate_weight_consistency cross-field validator."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_weight_used_exceeds_label_weight_returns_422(self, async_client: AsyncClient, spoolman_settings):
-        """weight_used > label_weight → cross-field validator → 422."""
-        resp = await async_client.post(
-            "/api/v1/spoolman/inventory/spools",
-            json={"material": "PLA", "label_weight": 500, "weight_used": 600},
-        )
-        assert resp.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_weight_used_equals_label_weight_accepted(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """weight_used == label_weight is exactly at the boundary → should pass (201)."""
-        resp = await async_client.post(
-            "/api/v1/spoolman/inventory/spools",
-            json={"material": "PLA", "label_weight": 1000, "weight_used": 1000},
-        )
-        # 201 or 200 (spool created)
-        assert resp.status_code in (200, 201)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_create_spool_with_non_default_core_weight_accepted(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """A3: core_weight != 250 must no longer be rejected → 201."""
-        resp = await async_client.post(
-            "/api/v1/spoolman/inventory/spools",
-            json={"material": "PLA", "label_weight": 1000, "weight_used": 0, "core_weight": 196},
-        )
-        assert resp.status_code in (200, 201)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_update_spool_with_non_default_core_weight_accepted(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """A3: PATCH with core_weight != 250 must no longer return 422."""
-        resp = await async_client.patch(
-            "/api/v1/spoolman/inventory/spools/42",
-            json={"core_weight": 300},
-        )
-        assert resp.status_code == 200
-
-
-# ---------------------------------------------------------------------------
-# P8-T1: /slot-assignments/all enriches with printer_name + ams_label
-# ---------------------------------------------------------------------------
-
-
-class TestGetAllSlotAssignmentsEnriched:
-    """P8-T1: /slot-assignments/all enriches with printer_name + ams_label.
-
-    Regression for InventoryPage LOCATION column showing '-' for Spoolman
-    spools because the endpoint only returned 4 raw fields without the
-    printer_name + ams_label needed by the UI.
-    """
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_returns_printer_name_for_existing_printer(
-        self, async_client: AsyncClient, db_session, spoolman_settings
-    ):
-        """printer_name is enriched from the joined Printer relationship."""
-        from backend.app.models.printer import Printer
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        db_session.add(
-            Printer(
-                id=1,
-                name="Sully",
-                model="X1C",
-                serial_number="SN1",
-                ip_address="1.2.3.4",
-                access_code="",
-            )
-        )
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=1,
-                ams_id=0,
-                tray_id=2,
-                spoolman_spool_id=216,
-            )
-        )
-        await db_session.commit()
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as mock_pm:
-            mock_pm.get_all_statuses.return_value = {}
-            resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
-
-        assert resp.status_code == 200
-        data = resp.json()
-        assert len(data) == 1
-        assert data[0]["printer_name"] == "Sully"
-        assert data[0]["spoolman_spool_id"] == 216
-        assert data[0]["ams_id"] == 0
-        assert data[0]["tray_id"] == 2
-        assert data[0]["ams_label"] is None
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_returns_ams_label_when_label_configured(
-        self, async_client: AsyncClient, db_session, spoolman_settings
-    ):
-        """ams_label is enriched from AmsLabel via printer MQTT serial map."""
-        from backend.app.models.ams_label import AmsLabel
-        from backend.app.models.printer import Printer
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        db_session.add(
-            Printer(
-                id=1,
-                name="Sully",
-                model="X1C",
-                serial_number="SN1",
-                ip_address="1.2.3.4",
-                access_code="",
-            )
-        )
-        db_session.add(AmsLabel(ams_serial_number="ABC123", label="Top Shelf"))
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=1,
-                ams_id=0,
-                tray_id=2,
-                spoolman_spool_id=216,
-            )
-        )
-        await db_session.commit()
-
-        mock_state = MagicMock(raw_data={"ams": [{"id": 0, "sn": "ABC123"}]})
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as mock_pm:
-            mock_pm.get_all_statuses.return_value = {1: mock_state}
-            resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
-
-        assert resp.status_code == 200
-        assert resp.json()[0]["ams_label"] == "Top Shelf"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_synthetic_ams_label_fallback(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """Falls back to synthetic 'p{pid}a{ams_id}' key when no MQTT serial available."""
-        from backend.app.models.ams_label import AmsLabel
-        from backend.app.models.printer import Printer
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        db_session.add(
-            Printer(
-                id=1,
-                name="Sully",
-                model="X1C",
-                serial_number="SN1",
-                ip_address="1.2.3.4",
-                access_code="",
-            )
-        )
-        db_session.add(AmsLabel(ams_serial_number="p1a0", label="Synthetic Label"))
-        db_session.add(
-            SpoolmanSlotAssignment(
-                printer_id=1,
-                ams_id=0,
-                tray_id=2,
-                spoolman_spool_id=216,
-            )
-        )
-        await db_session.commit()
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as mock_pm:
-            mock_pm.get_all_statuses.return_value = {}  # No live state -> synthetic key
-            resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
-
-        assert resp.json()[0]["ams_label"] == "Synthetic Label"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_filter_by_printer_id_still_works(self, async_client: AsyncClient, db_session, spoolman_settings):
-        """Regression: ?printer_id=N still filters and enriches."""
-        from backend.app.models.printer import Printer
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        for pid in (1, 2):
-            db_session.add(
-                Printer(
-                    id=pid,
-                    name=f"P{pid}",
-                    model="X1C",
-                    serial_number=f"SN{pid}",
-                    ip_address=f"1.2.3.{pid}",
-                    access_code="",
-                )
-            )
-            db_session.add(
-                SpoolmanSlotAssignment(
-                    printer_id=pid,
-                    ams_id=0,
-                    tray_id=0,
-                    spoolman_spool_id=200 + pid,
-                )
-            )
-        await db_session.commit()
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as mock_pm:
-            mock_pm.get_all_statuses.return_value = {}
-            resp = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all?printer_id=1")
-
-        data = resp.json()
-        assert len(data) == 1
-        assert data[0]["printer_id"] == 1
-        assert data[0]["printer_name"] == "P1"
-        assert data[0]["spoolman_spool_id"] == 201

+ 0 - 279
backend/tests/integration/test_spoolman_k_profiles.py

@@ -1,279 +0,0 @@
-"""Integration tests for Spoolman K-profile endpoints.
-
-Covers:
-  GET  /api/v1/spoolman/inventory/spools/{id}/k-profiles
-  PUT  /api/v1/spoolman/inventory/spools/{id}/k-profiles
-  GET  /api/v1/spoolman/inventory/spools/{id}  — k_profiles enrichment
-  GET  /api/v1/spoolman/inventory/spools       — k_profiles enrichment (batch)
-"""
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from httpx import AsyncClient
-
-SAMPLE_SPOOL = {
-    "id": 7,
-    "filament": {
-        "id": 1,
-        "name": "PETG CF",
-        "material": "PETG",
-        "weight": 1000,
-        "color_hex": "000000",
-        "vendor": {"id": 1, "name": "BrandX"},
-    },
-    "remaining_weight": 600.0,
-    "used_weight": 400.0,
-    "location": None,
-    "comment": None,
-    "first_used": None,
-    "last_used": None,
-    "registered": "2024-01-01T00:00:00+00:00",
-    "archived": False,
-    "price": None,
-    "extra": {},
-}
-
-
-@pytest.fixture
-async def kp_settings(db_session):
-    from backend.app.models.settings import Settings
-
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-    await db_session.commit()
-
-
-@pytest.fixture
-async def test_printer(db_session):
-    from backend.app.models.printer import Printer
-
-    printer = Printer(
-        name="KP Printer",
-        serial_number="KPTEST001",
-        ip_address="192.168.1.77",
-        access_code="12345678",
-    )
-    db_session.add(printer)
-    await db_session.commit()
-    await db_session.refresh(printer)
-    return printer
-
-
-@pytest.fixture
-def mock_spoolman_client():
-    client = MagicMock()
-    client.base_url = "http://localhost:7912"
-    client.health_check = AsyncMock(return_value=True)
-    client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
-    client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOL])
-
-    with patch(
-        "backend.app.api.routes.spoolman_inventory._get_client",
-        AsyncMock(return_value=client),
-    ):
-        yield client
-
-
-class TestGetSpoolmanKProfiles:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_returns_empty_list_when_none(self, async_client: AsyncClient, kp_settings, mock_spoolman_client):
-        """GET /spools/7/k-profiles returns [] when no profiles exist."""
-        response = await async_client.get("/api/v1/spoolman/inventory/spools/7/k-profiles")
-        assert response.status_code == 200
-        assert response.json() == []
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_returns_existing_profiles(
-        self, async_client: AsyncClient, kp_settings, mock_spoolman_client, test_printer, db_session
-    ):
-        """GET /spools/7/k-profiles returns saved profiles."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=7,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.025,
-            cali_idx=3,
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools/7/k-profiles")
-        assert response.status_code == 200
-        profiles = response.json()
-        assert len(profiles) == 1
-        assert profiles[0]["spool_id"] == 7
-        assert profiles[0]["printer_id"] == test_printer.id
-        assert profiles[0]["k_value"] == pytest.approx(0.025)
-        assert profiles[0]["cali_idx"] == 3
-
-
-class TestSaveSpoolmanKProfiles:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_put_creates_profiles(
-        self, async_client: AsyncClient, kp_settings, mock_spoolman_client, test_printer
-    ):
-        """PUT /spools/7/k-profiles saves profiles and returns them."""
-        response = await async_client.put(
-            "/api/v1/spoolman/inventory/spools/7/k-profiles",
-            json=[
-                {
-                    "printer_id": test_printer.id,
-                    "extruder": 0,
-                    "nozzle_diameter": "0.4",
-                    "k_value": 0.02,
-                    "cali_idx": 1,
-                }
-            ],
-        )
-        assert response.status_code == 200
-        saved = response.json()
-        assert len(saved) == 1
-        assert saved[0]["spool_id"] == 7
-        assert saved[0]["k_value"] == pytest.approx(0.02)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_put_replaces_existing_profiles(
-        self, async_client: AsyncClient, kp_settings, mock_spoolman_client, test_printer, db_session
-    ):
-        """PUT /spools/7/k-profiles with new data deletes old rows first."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        old = SpoolmanKProfile(
-            spoolman_spool_id=7,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.99,
-            cali_idx=99,
-        )
-        db_session.add(old)
-        await db_session.commit()
-
-        response = await async_client.put(
-            "/api/v1/spoolman/inventory/spools/7/k-profiles",
-            json=[
-                {
-                    "printer_id": test_printer.id,
-                    "extruder": 0,
-                    "nozzle_diameter": "0.4",
-                    "k_value": 0.03,
-                    "cali_idx": 7,
-                }
-            ],
-        )
-        assert response.status_code == 200
-        saved = response.json()
-        assert len(saved) == 1
-        assert saved[0]["k_value"] == pytest.approx(0.03)
-        assert saved[0]["cali_idx"] == 7
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_put_empty_clears_profiles(
-        self, async_client: AsyncClient, kp_settings, mock_spoolman_client, test_printer, db_session
-    ):
-        """PUT /spools/7/k-profiles with [] clears all existing profiles."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=7,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.02,
-            cali_idx=1,
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        response = await async_client.put(
-            "/api/v1/spoolman/inventory/spools/7/k-profiles",
-            json=[],
-        )
-        assert response.status_code == 200
-        assert response.json() == []
-
-        # Verify gone in DB
-        get_resp = await async_client.get("/api/v1/spoolman/inventory/spools/7/k-profiles")
-        assert get_resp.json() == []
-
-
-class TestSpoolKProfileEnrichment:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_spool_includes_k_profiles(
-        self, async_client: AsyncClient, kp_settings, mock_spoolman_client, test_printer, db_session
-    ):
-        """GET /spools/7 includes k_profiles from local DB."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=7,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.6",
-            k_value=0.018,
-            cali_idx=2,
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools/7")
-        assert response.status_code == 200
-        body = response.json()
-        assert "k_profiles" in body
-        assert len(body["k_profiles"]) == 1
-        assert body["k_profiles"][0]["k_value"] == pytest.approx(0.018)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_list_spools_includes_k_profiles(
-        self, async_client: AsyncClient, kp_settings, mock_spoolman_client, test_printer, db_session
-    ):
-        """GET /spools includes k_profiles for each spool from local DB."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=7,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.021,
-            cali_idx=4,
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        response = await async_client.get("/api/v1/spoolman/inventory/spools")
-        assert response.status_code == 200
-        spools = response.json()
-        assert len(spools) == 1
-        assert "k_profiles" in spools[0]
-        assert len(spools[0]["k_profiles"]) == 1
-        assert spools[0]["k_profiles"][0]["cali_idx"] == 4
-
-
-class TestPutSpoolmanKProfilesValidation:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_put_k_profiles_duplicate_raises_422(
-        self, async_client: AsyncClient, kp_settings, mock_spoolman_client, test_printer
-    ):
-        """Two profiles with identical (printer_id, extruder, nozzle_diameter) → UNIQUE violation → 422."""
-        profiles = [
-            {"printer_id": test_printer.id, "extruder": 0, "nozzle_diameter": "0.4", "k_value": 0.02},
-            {"printer_id": test_printer.id, "extruder": 0, "nozzle_diameter": "0.4", "k_value": 0.03},
-        ]
-        response = await async_client.put(
-            "/api/v1/spoolman/inventory/spools/7/k-profiles",
-            json=profiles,
-        )
-        assert response.status_code == 422

+ 0 - 863
backend/tests/integration/test_spoolman_slot_assignment_mqtt.py

@@ -1,863 +0,0 @@
-"""Integration tests for MQTT auto-configuration when assigning a Spoolman spool to an AMS slot.
-
-Covers:
-  - ams_set_filament_setting is called with correct parameters on assign
-  - extrusion_cali_sel is called when a matching K-profile exists
-  - MQTT failure does NOT roll back the slot assignment
-"""
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from httpx import AsyncClient
-
-SAMPLE_SPOOL = {
-    "id": 10,
-    "filament": {
-        "id": 1,
-        "name": "PLA Basic",
-        "material": "PLA",
-        "color_hex": "FF0000",
-        "weight": 1000,
-        "vendor": {"id": 1, "name": "BrandX"},
-    },
-    "remaining_weight": 800.0,
-    "used_weight": 200.0,
-    "location": None,
-    "comment": None,
-    "first_used": None,
-    "last_used": None,
-    "registered": "2024-01-01T00:00:00+00:00",
-    "archived": False,
-    "price": None,
-    "extra": {},
-}
-
-
-@pytest.fixture
-async def slot_settings(db_session):
-    from backend.app.models.settings import Settings
-
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-    await db_session.commit()
-
-
-@pytest.fixture
-async def test_printer(db_session):
-    from backend.app.models.printer import Printer
-
-    printer = Printer(
-        name="MQTT Printer",
-        serial_number="MQTTTEST001",
-        ip_address="192.168.1.200",
-        access_code="12345678",
-    )
-    db_session.add(printer)
-    await db_session.commit()
-    await db_session.refresh(printer)
-    return printer
-
-
-@pytest.fixture
-def mock_spoolman_client():
-    client = MagicMock()
-    client.base_url = "http://localhost:7912"
-    client.health_check = AsyncMock(return_value=True)
-    client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
-
-    with patch(
-        "backend.app.api.routes.spoolman_inventory._get_client",
-        AsyncMock(return_value=client),
-    ):
-        yield client
-
-
-class TestAssignSlotMqtt:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_mqtt_ams_set_filament_called_on_assign(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
-    ):
-        """Assigning a Spoolman spool fires ams_set_filament_setting via MQTT."""
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        mqtt_mock.printer_state = None
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-
-            response = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 1,
-                },
-            )
-
-        assert response.status_code == 200
-        mqtt_mock.ams_set_filament_setting.assert_called_once()
-        call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
-        assert call_kwargs["ams_id"] == 0
-        assert call_kwargs["tray_id"] == 1
-        assert call_kwargs["tray_type"] == "PLA"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_mqtt_failure_does_not_rollback_assignment(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
-    ):
-        """A crash inside the MQTT block must not un-persist the slot assignment."""
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock(side_effect=RuntimeError("MQTT down"))
-        mqtt_mock.printer_state = None
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-
-            response = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 1,
-                    "tray_id": 0,
-                },
-            )
-
-        assert response.status_code == 200
-
-        # Verify the assignment IS in the DB despite the MQTT crash
-        all_resp = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments/all",
-            params={"printer_id": test_printer.id},
-        )
-        assert all_resp.status_code == 200
-        rows = all_resp.json()
-        assert any(r["spoolman_spool_id"] == 10 for r in rows)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_extrusion_cali_sel_called_when_k_profile_exists(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """extrusion_cali_sel is fired when a matching SpoolmanKProfile row exists."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.02,
-            cali_idx=5,
-            setting_id="CaliID",
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        printer_state = MagicMock()
-        printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
-        printer_state.ams_extruder_map = {"0": 0}
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 2,
-                },
-            )
-
-        assert response.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_called_once()
-        call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 5
-        assert call_kwargs["ams_id"] == 0
-        assert call_kwargs["tray_id"] == 2
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_extrusion_cali_sel_not_called_on_nozzle_mismatch(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """extrusion_cali_sel is NOT called when nozzle diameter does not match K-profile."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.6",
-            k_value=0.03,
-            cali_idx=7,
-            setting_id="CaliID",
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        printer_state = MagicMock()
-        printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
-        printer_state.ams_extruder_map = {"0": 0}
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 3,
-                },
-            )
-
-        assert response.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_extrusion_cali_sel_not_called_when_cali_idx_none(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """extrusion_cali_sel is NOT called when K-profile has cali_idx=None."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.02,
-            cali_idx=None,
-            setting_id=None,
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        printer_state = MagicMock()
-        printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
-        printer_state.ams_extruder_map = {"0": 0}
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 3,
-                },
-            )
-
-        assert response.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
-
-
-# ---------------------------------------------------------------------------
-# F7: ams_id=255 External-Slot Extruder-Inversion
-# ---------------------------------------------------------------------------
-
-
-class TestExternalSlotExtruderInversion:
-    """F7: ams_id=255 maps tray_id→extruder via inversion (0→1, 1→0)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_external_slot_tray0_maps_to_extruder1(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """tray_id=0 on ams_id=255 → extruder=1 (ext-L)."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        # Create K-profiles for both extruders so we can verify which one matches
-        kp_extruder_1 = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=1,
-            nozzle_diameter="0.4",
-            k_value=0.03,
-            cali_idx=1,
-            setting_id=None,
-        )
-        db_session.add(kp_extruder_1)
-        await db_session.commit()
-
-        printer_state = MagicMock()
-        printer_state.nozzles = [MagicMock(nozzle_diameter="0.4"), MagicMock(nozzle_diameter="0.4")]
-        printer_state.ams_extruder_map = {"0": 0}  # present so external inversion logic triggers
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 255,
-                    "tray_id": 0,
-                },
-            )
-
-        assert resp.status_code == 200
-        # extrusion_cali_sel should be called with the K-profile for extruder=1 (cali_idx=1)
-        # The extruder itself is not passed as an argument — it's used internally to filter profiles
-        mqtt_mock.extrusion_cali_sel.assert_called_once()
-        call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_external_slot_tray1_maps_to_extruder0(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """tray_id=1 on ams_id=255 → extruder=0 (ext-R)."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp_extruder_0 = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.02,
-            cali_idx=2,
-            setting_id=None,
-        )
-        db_session.add(kp_extruder_0)
-        await db_session.commit()
-
-        printer_state = MagicMock()
-        printer_state.nozzles = [MagicMock(nozzle_diameter="0.4"), MagicMock(nozzle_diameter="0.4")]
-        printer_state.ams_extruder_map = {"0": 0}
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 255,
-                    "tray_id": 1,
-                },
-            )
-
-        assert resp.status_code == 200
-        # extrusion_cali_sel should be called with the K-profile for extruder=0 (cali_idx=2)
-        mqtt_mock.extrusion_cali_sel.assert_called_once()
-        call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 2
-
-
-# ---------------------------------------------------------------------------
-# P9-TEST-BE: Live cali_idx fallback when no K-profile is stored (Bug #10)
-# ---------------------------------------------------------------------------
-
-
-class TestAssignSpoolmanSlotLiveCaliIdx:
-    """When no SpoolmanKProfile exists, live tray cali_idx is used as fallback."""
-
-    def _make_printer_state(self, ams_id: int, tray_id: int, cali_idx: int | None):
-        """Build a minimal printer_state mock with one AMS tray."""
-        tray_mock = {
-            "id": tray_id,
-            "cali_idx": cali_idx,
-        }
-        ams_mock = {"id": ams_id, "tray": [tray_mock]}
-        state = MagicMock()
-        state.nozzles = [MagicMock(nozzle_diameter="0.4")]
-        state.ams_extruder_map = {str(ams_id): 0}
-        state.raw_data = {"ams": [ams_mock]}
-        return state
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_kprofile_uses_live_cali_idx(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
-    ):
-        """When no K-profile exists, live tray cali_idx is sent via extrusion_cali_sel."""
-        printer_state = self._make_printer_state(ams_id=0, tray_id=1, cali_idx=42)
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 1,
-                },
-            )
-
-        assert resp.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_called_once()
-        call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        assert call_kwargs["cali_idx"] == 42
-        assert call_kwargs["ams_id"] == 0
-        assert call_kwargs["tray_id"] == 1
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_no_kprofile_no_live_cali_idx_nothing_sent(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
-    ):
-        """When no K-profile and tray has no cali_idx, extrusion_cali_sel is not called."""
-        printer_state = self._make_printer_state(ams_id=0, tray_id=2, cali_idx=None)
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 2,
-                },
-            )
-
-        assert resp.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_kprofile_takes_priority_over_live_cali_idx(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """Stored K-profile cali_idx wins over live tray cali_idx."""
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.02,
-            cali_idx=10,
-            setting_id="CaliID",
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        # Live tray has a different cali_idx — stored profile must win
-        printer_state = self._make_printer_state(ams_id=0, tray_id=3, cali_idx=99)
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 3,
-                },
-            )
-
-        assert resp.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_called_once()
-        call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        # Must use stored K-profile (10), NOT live cali_idx (99)
-        assert call_kwargs["cali_idx"] == 10
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_live_cali_idx_not_used_if_negative(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
-    ):
-        """A negative live cali_idx is invalid and must not be sent."""
-        printer_state = self._make_printer_state(ams_id=0, tray_id=0, cali_idx=-1)
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        # Legacy attribute — production never had it set; keep for any code
-        # path that still reads `mqtt_client.printer_state` directly. State
-        # for the K-profile cascade now comes from printer_manager.get_status.
-        mqtt_mock.printer_state = printer_state
-        # Empty list = no printer-side kprofiles, so the realignment skips
-        # printer_kp lookup. Tests that exercise realignment explicitly
-        # populate this list themselves.
-        if (
-            not hasattr(printer_state, "kprofiles")
-            or printer_state.kprofiles is None
-            or isinstance(printer_state.kprofiles, MagicMock)
-        ):
-            printer_state.kprofiles = []
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            resp = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 0,
-                },
-            )
-
-        assert resp.status_code == 200
-        mqtt_mock.extrusion_cali_sel.assert_not_called()
-
-
-# ---------------------------------------------------------------------------
-# Realignment of slot filament context to K-profile preset
-# ---------------------------------------------------------------------------
-# When the user assigns a Spoolman spool whose stored kp was calibrated under
-# a specific filament preset (e.g. P-prefix local, or a named cloud preset),
-# the slot must be configured under THAT preset for the printer to find the
-# cali_idx in its calibration table. Without realignment the slot ends up on
-# generic PLA / default K — the symptom maztiggy reported on x1c-2 (#1114).
-
-
-class TestAssignSpoolmanSlotKProfileRealignment:
-    """assign_spoolman_slot realigns tray_info_idx + setting_id to kp context."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_realigns_to_printer_reported_filament_id(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """When state.kprofiles has the cali_idx, use printer_kp.filament_id verbatim.
-
-        The printer keys its calibration table by filament_id, not setting_id.
-        For a P-prefix local preset (printer-registered), filament_id and
-        tray_info_idx must match for the cali_idx to apply.
-        """
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        # Stored kp with setting_id but no filament_id (the schema gap)
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.025,
-            cali_idx=8948,
-            setting_id="PFUSedbf16b803ff3e",
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        printer_state = MagicMock()
-        printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
-        printer_state.ams_extruder_map = {"0": 0}
-        printer_state.raw_data = None
-        # Live calibration entry from the printer — this is what cali_idx 8948
-        # is actually registered under. P-prefix is a printer-local preset
-        # (different from PFUS-prefix cloud user presets).
-        printer_kp = MagicMock()
-        printer_kp.slot_id = 8948
-        printer_kp.nozzle_diameter = "0.4"
-        printer_kp.filament_id = "P4d64437"
-        printer_kp.setting_id = "PFUSedbf16b803ff3e"
-        printer_state.kprofiles = [printer_kp]
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        mqtt_mock.printer_state = printer_state
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 1,
-                },
-            )
-
-        assert response.status_code == 200
-        # Both MQTT commands must reference the printer-reported filament_id
-        # so the slot context and the cali_sel context match.
-        amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
-        assert amf_kwargs["tray_info_idx"] == "P4d64437"
-        assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
-        cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        assert cs_kwargs["cali_idx"] == 8948
-        assert cs_kwargs["filament_id"] == "P4d64437"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_skips_realignment_for_pfus_prefix(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """PFUS-prefix cloud-user presets are rejected by the slicer in tray_info_idx.
-
-        For those, tray_info_idx must stay as the GF* generic so the slicer
-        can render the slot. setting_id can still be realigned to the cloud
-        preset (slicer uses that for display), but tray_info_idx stays GF*.
-        """
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=0,
-            nozzle_diameter="0.4",
-            k_value=0.025,
-            cali_idx=42,
-            setting_id="PFUSedbf16b803ff3e",
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        printer_state = MagicMock()
-        printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
-        printer_state.ams_extruder_map = {"0": 0}
-        printer_state.raw_data = None
-        # Printer-side kp filament_id is PFUS-prefix → realignment must skip
-        printer_kp = MagicMock()
-        printer_kp.slot_id = 42
-        printer_kp.nozzle_diameter = "0.4"
-        printer_kp.filament_id = "PFUSedbf16b803ff3e"
-        printer_kp.setting_id = "PFUSedbf16b803ff3e"
-        printer_state.kprofiles = [printer_kp]
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        mqtt_mock.printer_state = printer_state
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 2,
-                },
-            )
-
-        assert response.status_code == 200
-        amf_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
-        # tray_info_idx stays as the resolved generic (slicer accepts GF*)
-        assert amf_kwargs["tray_info_idx"] == "GFL99"
-        # setting_id may be realigned to the cloud preset for slicer display
-        assert amf_kwargs["setting_id"] == "PFUSedbf16b803ff3e"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_extruder_relax_falls_back_to_any_extruder_kp(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
-    ):
-        """Hard-skip on extruder mismatch silently dropped valid stored profiles
-        when the AMS-extruder map shifted. The cascade now prefers exact
-        extruder match but falls back to any kp on the same printer + nozzle.
-        """
-        from backend.app.models.spoolman_k_profile import SpoolmanKProfile
-
-        # kp is for extruder=1, but slot will be on extruder=0 (mismatch)
-        kp = SpoolmanKProfile(
-            spoolman_spool_id=10,
-            printer_id=test_printer.id,
-            extruder=1,
-            nozzle_diameter="0.4",
-            k_value=0.025,
-            cali_idx=42,
-            setting_id="GFSL05",
-        )
-        db_session.add(kp)
-        await db_session.commit()
-
-        printer_state = MagicMock()
-        printer_state.nozzles = [MagicMock(nozzle_diameter="0.4")]
-        printer_state.ams_extruder_map = {"0": 0}
-        printer_state.raw_data = None
-        printer_state.kprofiles = []
-
-        mqtt_mock = MagicMock()
-        mqtt_mock.ams_set_filament_setting = MagicMock()
-        mqtt_mock.extrusion_cali_sel = MagicMock()
-        mqtt_mock.printer_state = printer_state
-
-        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
-            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
-            pm_mock.get_status = MagicMock(return_value=printer_state)
-
-            response = await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": 10,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": 3,
-                },
-            )
-
-        assert response.status_code == 200
-        # extruder mismatch was hard-skipped pre-fix; now used as fallback
-        cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-        assert cs_kwargs["cali_idx"] == 42

+ 0 - 549
backend/tests/integration/test_spoolman_slot_assignments.py

@@ -1,549 +0,0 @@
-"""Integration tests for Spoolman slot-assignment endpoints.
-
-Tests for:
-  POST   /api/v1/spoolman/inventory/slot-assignments
-  DELETE /api/v1/spoolman/inventory/slot-assignments/{spoolman_spool_id}
-  GET    /api/v1/spoolman/inventory/slot-assignments?printer_id=&ams_id=&tray_id=
-  GET    /api/v1/spoolman/inventory/slot-assignments/all[?printer_id=]
-
-Slot assignments are now stored in the local ``spoolman_slot_assignments`` table.
-Spoolman's ``spool.location`` field is NOT touched by any of these endpoints.
-"""
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from httpx import AsyncClient
-from sqlalchemy import select
-
-SAMPLE_SPOOL = {
-    "id": 10,
-    "filament": {
-        "id": 1,
-        "name": "PLA Basic",
-        "material": "PLA",
-        "color_hex": "FF0000",
-        "weight": 1000,
-        "vendor": {"id": 1, "name": "Test Brand"},
-    },
-    "remaining_weight": 800.0,
-    "used_weight": 200.0,
-    "location": None,
-    "comment": None,
-    "first_used": None,
-    "last_used": None,
-    "registered": "2024-01-01T00:00:00+00:00",
-    "archived": False,
-    "price": None,
-    "extra": {},
-}
-
-
-@pytest.fixture
-async def slot_settings(db_session):
-    from backend.app.models.settings import Settings
-
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-    await db_session.commit()
-
-
-@pytest.fixture
-async def test_printer(db_session):
-    from backend.app.models.printer import Printer
-
-    printer = Printer(
-        name="Test Printer",
-        serial_number="SLOTTEST001",
-        ip_address="192.168.1.100",
-        access_code="12345678",
-    )
-    db_session.add(printer)
-    await db_session.commit()
-    await db_session.refresh(printer)
-    return printer
-
-
-@pytest.fixture
-def mock_client():
-    client = MagicMock()
-    client.base_url = "http://localhost:7912"
-    client.health_check = AsyncMock(return_value=True)
-    client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
-
-    with patch(
-        "backend.app.api.routes.spoolman_inventory._get_client",
-        AsyncMock(return_value=client),
-    ):
-        yield client
-
-
-class TestAssignSpoolmanSlot:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_assign_inserts_local_row(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
-        """POST /slot-assignments creates a row visible via the /all endpoint."""
-        response = await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={
-                "spoolman_spool_id": 10,
-                "printer_id": test_printer.id,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-
-        assert response.status_code == 200
-        all_resp = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments/all",
-            params={"printer_id": test_printer.id},
-        )
-        assert all_resp.status_code == 200
-        rows = all_resp.json()
-        assert len(rows) == 1
-        assert rows[0]["spoolman_spool_id"] == 10
-        assert rows[0]["ams_id"] == 0
-        assert rows[0]["tray_id"] == 0
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_assign_does_not_call_update_spool(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client
-    ):
-        """POST /slot-assignments must NOT write to Spoolman's location field."""
-        response = await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={
-                "spoolman_spool_id": 10,
-                "printer_id": test_printer.id,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-
-        assert response.status_code == 200
-        mock_client.update_spool.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_assign_returns_inventory_spool(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client
-    ):
-        """POST /slot-assignments response is mapped to InventorySpool format."""
-        response = await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={
-                "spoolman_spool_id": 10,
-                "printer_id": test_printer.id,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-
-        assert response.status_code == 200
-        body = response.json()
-        assert body["id"] == 10
-        assert body["material"] == "PLA"
-        assert body["data_origin"] == "spoolman"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_assign_upserts_on_conflict(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client
-    ):
-        """POST /slot-assignments twice for the same slot replaces the old spool ID."""
-        # First assign spool 99
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={
-                "spoolman_spool_id": 99,
-                "printer_id": test_printer.id,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-        # Re-assign spool 10 to the same slot
-        response = await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={
-                "spoolman_spool_id": 10,
-                "printer_id": test_printer.id,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-        assert response.status_code == 200
-
-        # The /all endpoint must report exactly one row for this slot with spool_id=10
-        all_resp = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments/all",
-            params={"printer_id": test_printer.id},
-        )
-        assert all_resp.status_code == 200
-        rows = all_resp.json()
-        matched = [r for r in rows if r["ams_id"] == 0 and r["tray_id"] == 0]
-        assert len(matched) == 1
-        assert matched[0]["spoolman_spool_id"] == 10
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_assign_printer_not_found(self, async_client: AsyncClient, slot_settings, mock_client):
-        """POST /slot-assignments with unknown printer_id returns 404."""
-        response = await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={
-                "spoolman_spool_id": 10,
-                "printer_id": 99999,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_assign_invalid_spool_id(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
-        """POST /slot-assignments with spool_id=0 returns 422 (gt=0 validation)."""
-        response = await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={
-                "spoolman_spool_id": 0,
-                "printer_id": test_printer.id,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-
-        assert response.status_code == 422
-
-
-class TestUnassignSpoolmanSlot:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unassign_deletes_local_row(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client
-    ):
-        """DELETE /slot-assignments/{id} removes the row so /all no longer lists it."""
-        # First assign spool 10
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={
-                "spoolman_spool_id": 10,
-                "printer_id": test_printer.id,
-                "ams_id": 0,
-                "tray_id": 0,
-            },
-        )
-        # Then unassign
-        response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
-        assert response.status_code == 200
-
-        # The /all endpoint must now return an empty list for this printer
-        all_resp = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments/all",
-            params={"printer_id": test_printer.id},
-        )
-        assert all_resp.status_code == 200
-        assert all_resp.json() == []
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unassign_does_not_call_update_spool(self, async_client: AsyncClient, slot_settings, mock_client):
-        """DELETE /slot-assignments/{id} must NOT touch Spoolman's location field."""
-        response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
-
-        assert response.status_code == 200
-        mock_client.update_spool.assert_not_called()
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unassign_returns_inventory_spool(self, async_client: AsyncClient, slot_settings, mock_client):
-        """DELETE /slot-assignments/{id} returns the spool in InventorySpool format."""
-        response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
-
-        assert response.status_code == 200
-        body = response.json()
-        assert body["id"] == 10
-        assert body["data_origin"] == "spoolman"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unassign_invalid_id(self, async_client: AsyncClient, slot_settings, mock_client):
-        """DELETE /slot-assignments/0 returns 422 (gt=0 path validation)."""
-        response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/0")
-
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_unassign_succeeds_when_spool_deleted_in_spoolman(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
-    ):
-        """DELETE /slot-assignments/{id} returns 200 even when the spool no longer exists in Spoolman.
-
-        The local row must be removed regardless — the caller should not see an error just
-        because Spoolman has already discarded the spool.
-        """
-        from sqlalchemy import select
-
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        # Create the assignment first
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-
-        # Spool 10 has since been deleted from Spoolman
-        mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("spool 10 not found"))
-
-        response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
-
-        assert response.status_code == 200
-        assert response.json().get("id") == 10
-
-        # Local row must be gone
-        result = await db_session.execute(
-            select(SpoolmanSlotAssignment).where(
-                SpoolmanSlotAssignment.printer_id == test_printer.id,
-                SpoolmanSlotAssignment.ams_id == 0,
-                SpoolmanSlotAssignment.tray_id == 0,
-            )
-        )
-        assert result.scalar_one_or_none() is None
-
-
-class TestGetSpoolmanSlotAssignment:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_returns_matched_spool(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
-        """GET /slot-assignments returns the spool whose ID is in the local table."""
-        # First assign so the row exists
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-        mock_client.get_spool.reset_mock()
-
-        response = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-
-        assert response.status_code == 200
-        body = response.json()
-        assert body is not None
-        assert body["id"] == 10
-        mock_client.get_spool.assert_awaited_once_with(10)
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_returns_null_when_no_assignment(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client
-    ):
-        """GET /slot-assignments returns null when no local row exists for the slot."""
-        response = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            params={"printer_id": test_printer.id, "ams_id": 1, "tray_id": 0},
-        )
-
-        assert response.status_code == 200
-        assert response.json() is None
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_printer_not_found(self, async_client: AsyncClient, slot_settings, mock_client):
-        """GET /slot-assignments with unknown printer_id returns 404."""
-        response = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            params={"printer_id": 99999, "ams_id": 0, "tray_id": 0},
-        )
-
-        assert response.status_code == 404
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_missing_params(self, async_client: AsyncClient, slot_settings, mock_client):
-        """GET /slot-assignments without required params returns 422."""
-        response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments")
-
-        assert response.status_code == 422
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_returns_null_and_cleans_stale_when_spool_deleted_in_spoolman(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
-    ):
-        """GET /slot-assignments returns null and removes the stale row when Spoolman returns 404."""
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        # Assign spool 10 first
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-
-        # Simulate spool 10 being deleted from Spoolman (404 via SpoolmanNotFoundError)
-        mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("spool 10 not found"))
-
-        response = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-
-        assert response.status_code == 200
-        assert response.json() is None
-
-        # Stale row must have been removed
-        await db_session.refresh(test_printer)  # ensure session is fresh
-        result = await db_session.execute(
-            select(SpoolmanSlotAssignment).where(
-                SpoolmanSlotAssignment.printer_id == test_printer.id,
-                SpoolmanSlotAssignment.ams_id == 0,
-                SpoolmanSlotAssignment.tray_id == 0,
-            )
-        )
-        assert result.scalar_one_or_none() is None
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_propagates_503_from_spoolman(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client
-    ):
-        """GET /slot-assignments propagates a 503 from Spoolman instead of silently returning null."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        # Assign spool 10 first so a local row exists
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-
-        # Simulate Spoolman being unreachable
-        mock_client.get_spool = AsyncMock(side_effect=SpoolmanUnavailableError("timeout"))
-
-        response = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-
-        assert response.status_code == 503
-
-
-class TestGetAllSpoolmanSlotAssignments:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_all_returns_empty_list(self, async_client: AsyncClient, slot_settings, mock_client):
-        """GET /slot-assignments/all returns [] when no assignments exist."""
-        response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
-
-        assert response.status_code == 200
-        assert response.json() == []
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_all_returns_all_rows(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
-        """GET /slot-assignments/all returns all existing assignments."""
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={"spoolman_spool_id": 20, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 1},
-        )
-
-        response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
-
-        assert response.status_code == 200
-        body = response.json()
-        assert len(body) == 2
-        spool_ids = {r["spoolman_spool_id"] for r in body}
-        assert spool_ids == {10, 20}
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_get_all_filters_by_printer(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
-    ):
-        """GET /slot-assignments/all?printer_id=X only returns that printer's rows."""
-        from backend.app.models.printer import Printer
-
-        # Create a second printer via DB directly (no Spoolman mock needed for printer creation)
-        other = Printer(
-            name="Other Printer",
-            serial_number="SLOTTEST002",
-            ip_address="192.168.1.101",
-            access_code="87654321",
-        )
-        db_session.add(other)
-        await db_session.commit()
-        await db_session.refresh(other)
-
-        # Assign via API for test_printer
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
-        )
-        # Assign via API for other printer
-        await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={"spoolman_spool_id": 99, "printer_id": other.id, "ams_id": 0, "tray_id": 0},
-        )
-
-        response = await async_client.get(
-            "/api/v1/spoolman/inventory/slot-assignments/all",
-            params={"printer_id": test_printer.id},
-        )
-
-        assert response.status_code == 200
-        body = response.json()
-        assert len(body) == 1
-        assert body[0]["spoolman_spool_id"] == 10
-        assert body[0]["printer_id"] == test_printer.id
-
-
-class TestCascadeDeletePrinter:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_delete_printer_removes_slot_assignments(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
-    ):
-        """DELETE /printers/{id} removes all slot assignments for that printer.
-
-        SQLite does not enforce FK cascades automatically. The delete_printer
-        endpoint must explicitly delete SpoolmanSlotAssignment rows so no
-        orphaned rows survive after the printer record is gone.
-        """
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        # Assign two spools to different AMS slots on the test printer
-        for tray_id, spool_id in [(0, 10), (1, 20)]:
-            await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json={
-                    "spoolman_spool_id": spool_id,
-                    "printer_id": test_printer.id,
-                    "ams_id": 0,
-                    "tray_id": tray_id,
-                },
-            )
-
-        # Verify both rows exist
-        pre = await db_session.execute(
-            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
-        )
-        assert len(pre.scalars().all()) == 2
-
-        # Delete the printer
-        del_resp = await async_client.delete(f"/api/v1/printers/{test_printer.id}")
-        assert del_resp.status_code == 200
-
-        # All slot assignment rows for the deleted printer must be gone
-        post = await db_session.execute(
-            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
-        )
-        assert post.scalars().all() == []

+ 0 - 144
backend/tests/integration/test_spoolman_slot_concurrency.py

@@ -1,144 +0,0 @@
-"""T-Gap 3: Concurrency test for POST /slot-assignments upsert+cleanup race."""
-
-import asyncio
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from httpx import AsyncClient
-from sqlalchemy import select
-
-SAMPLE_SPOOL = {
-    "id": 10,
-    "filament": {
-        "id": 1,
-        "name": "PLA Basic",
-        "material": "PLA",
-        "color_hex": "FF0000",
-        "weight": 1000,
-        "vendor": {"id": 1, "name": "Test Brand"},
-    },
-    "remaining_weight": 800.0,
-    "used_weight": 200.0,
-    "location": None,
-    "comment": None,
-    "first_used": None,
-    "last_used": None,
-    "registered": "2024-01-01T00:00:00+00:00",
-    "archived": False,
-    "price": None,
-    "extra": {},
-}
-
-
-@pytest.fixture
-async def slot_settings(db_session):
-    from backend.app.models.settings import Settings
-
-    db_session.add(Settings(key="spoolman_enabled", value="true"))
-    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
-    await db_session.commit()
-
-
-@pytest.fixture
-async def test_printer(db_session):
-    from backend.app.models.printer import Printer
-
-    printer = Printer(
-        name="Concurrency Test Printer",
-        serial_number="CONCTEST001",
-        ip_address="192.168.1.99",
-        access_code="12345678",
-    )
-    db_session.add(printer)
-    await db_session.commit()
-    await db_session.refresh(printer)
-    return printer
-
-
-@pytest.fixture
-def mock_client():
-    client = MagicMock()
-    client.base_url = "http://localhost:7912"
-    client.health_check = AsyncMock(return_value=True)
-    client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
-
-    with patch(
-        "backend.app.api.routes.spoolman_inventory._get_client",
-        AsyncMock(return_value=client),
-    ):
-        yield client
-
-
-class TestSlotAssignmentConcurrency:
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_concurrent_assign_same_slot_idempotent(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
-    ):
-        """Concurrent POST requests for the same slot must not produce duplicate rows."""
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        payload = {
-            "spoolman_spool_id": 10,
-            "printer_id": test_printer.id,
-            "ams_id": 0,
-            "tray_id": 0,
-        }
-
-        async def assign():
-            return await async_client.post(
-                "/api/v1/spoolman/inventory/slot-assignments",
-                json=payload,
-            )
-
-        responses = await asyncio.gather(assign(), assign(), assign())
-        for resp in responses:
-            assert resp.status_code == 200
-
-        # Exactly one row for this (printer, ams, tray) combination
-        result = await db_session.execute(
-            select(SpoolmanSlotAssignment).where(
-                SpoolmanSlotAssignment.printer_id == test_printer.id,
-                SpoolmanSlotAssignment.ams_id == 0,
-                SpoolmanSlotAssignment.tray_id == 0,
-            )
-        )
-        rows = result.scalars().all()
-        assert len(rows) == 1
-        assert rows[0].spoolman_spool_id == 10
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_reassign_slot_updates_spool_id(
-        self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
-    ):
-        """Re-assigning a slot to a different spool updates the existing row."""
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        base = {"printer_id": test_printer.id, "ams_id": 1, "tray_id": 2}
-
-        resp1 = await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={**base, "spoolman_spool_id": 10},
-        )
-        assert resp1.status_code == 200
-
-        # Re-assign same slot to a different spool
-        mock_client.get_spool.return_value = {**SAMPLE_SPOOL, "id": 20}
-        resp2 = await async_client.post(
-            "/api/v1/spoolman/inventory/slot-assignments",
-            json={**base, "spoolman_spool_id": 20},
-        )
-        assert resp2.status_code == 200
-
-        # Only one row; spool_id updated to 20
-        result = await db_session.execute(
-            select(SpoolmanSlotAssignment).where(
-                SpoolmanSlotAssignment.printer_id == test_printer.id,
-                SpoolmanSlotAssignment.ams_id == 1,
-                SpoolmanSlotAssignment.tray_id == 2,
-            )
-        )
-        rows = result.scalars().all()
-        assert len(rows) == 1
-        assert rows[0].spoolman_spool_id == 20

+ 0 - 126
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -1239,129 +1239,3 @@ async def test_find_matching_untagged_gradient_no_match_basic(db_session):
     }
     found = await find_matching_untagged_spool(db_session, tray)
     assert found is None
-
-
-# -- auto_assign_spool: live cali_idx fallback (P9-3) -------------------------
-
-
-def _make_state_with_tray(ams_id: int, tray_id: int, cali_idx):
-    from unittest.mock import MagicMock
-
-    tray_data = {"id": tray_id, "cali_idx": cali_idx, "tray_color": "FF0000FF", "tray_type": "PLA"}
-    ams_data = [{"id": ams_id, "tray": [tray_data]}]
-    state = MagicMock()
-    state.nozzles = []
-    state.raw_data = {"ams": ams_data}
-    return state
-
-
-@pytest.mark.asyncio
-async def test_auto_assign_no_kprofile_uses_live_cali_idx(db_session, printer_factory):
-    """When no K-profile exists, live tray cali_idx is preserved via extrusion_cali_sel."""
-    from unittest.mock import MagicMock
-
-    printer = await printer_factory()
-    spool = Spool(material="PLA", label_weight=1000, core_weight=250)
-    spool.k_profiles = []
-    spool.assignments = []
-    db_session.add(spool)
-    await db_session.flush()
-
-    mqtt_mock = MagicMock()
-    state = _make_state_with_tray(ams_id=0, tray_id=1, cali_idx=42)
-    mock_pm = MagicMock()
-    mock_pm.get_status.return_value = state
-    mock_pm.get_client.return_value = mqtt_mock
-
-    await auto_assign_spool(printer.id, 0, 1, spool, mock_pm, db_session)
-    await db_session.commit()
-
-    mqtt_mock.extrusion_cali_sel.assert_called_once()
-    call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-    assert call_kwargs["cali_idx"] == 42
-    assert call_kwargs["ams_id"] == 0
-    assert call_kwargs["tray_id"] == 1
-
-
-@pytest.mark.asyncio
-async def test_auto_assign_no_kprofile_no_live_cali_idx_nothing_sent(db_session, printer_factory):
-    """When tray has no cali_idx, extrusion_cali_sel is not called."""
-    from unittest.mock import MagicMock
-
-    printer = await printer_factory()
-    spool = Spool(material="PLA", label_weight=1000, core_weight=250)
-    spool.k_profiles = []
-    spool.assignments = []
-    db_session.add(spool)
-    await db_session.flush()
-
-    mqtt_mock = MagicMock()
-    state = _make_state_with_tray(ams_id=0, tray_id=0, cali_idx=None)
-    mock_pm = MagicMock()
-    mock_pm.get_status.return_value = state
-    mock_pm.get_client.return_value = mqtt_mock
-
-    await auto_assign_spool(printer.id, 0, 0, spool, mock_pm, db_session)
-    await db_session.commit()
-
-    mqtt_mock.extrusion_cali_sel.assert_not_called()
-
-
-@pytest.mark.asyncio
-async def test_auto_assign_negative_live_cali_idx_not_sent(db_session, printer_factory):
-    """A negative live cali_idx (-1) is invalid and must not be sent."""
-    from unittest.mock import MagicMock
-
-    printer = await printer_factory()
-    spool = Spool(material="PLA", label_weight=1000, core_weight=250)
-    spool.k_profiles = []
-    spool.assignments = []
-    db_session.add(spool)
-    await db_session.flush()
-
-    mqtt_mock = MagicMock()
-    state = _make_state_with_tray(ams_id=0, tray_id=0, cali_idx=-1)
-    mock_pm = MagicMock()
-    mock_pm.get_status.return_value = state
-    mock_pm.get_client.return_value = mqtt_mock
-
-    await auto_assign_spool(printer.id, 0, 0, spool, mock_pm, db_session)
-    await db_session.commit()
-
-    mqtt_mock.extrusion_cali_sel.assert_not_called()
-
-
-@pytest.mark.asyncio
-async def test_auto_assign_kprofile_takes_priority_over_live_cali_idx(db_session, printer_factory):
-    """Stored K-profile wins over live tray cali_idx."""
-    from unittest.mock import MagicMock, patch
-
-    printer = await printer_factory()
-
-    kp_mock = MagicMock()
-    kp_mock.printer_id = printer.id
-    kp_mock.nozzle_diameter = "0.4"
-    kp_mock.cali_idx = 7
-    kp_mock.extruder = None
-
-    # Use a fully-mocked spool so SA relationship instrumentation is bypassed.
-    # auto_assign_spool only reads attributes — it never persists via the spool.
-    spool = MagicMock(spec=Spool)
-    spool.id = 999
-    spool.material = "PLA"
-    spool.slicer_filament = None
-    spool.k_profiles = [kp_mock]
-    spool.assignments = []
-
-    mqtt_mock = MagicMock()
-    # Live tray has cali_idx=99 — stored profile (7) must win
-    state = _make_state_with_tray(ams_id=0, tray_id=0, cali_idx=99)
-    mock_pm = MagicMock()
-    mock_pm.get_status.return_value = state
-    mock_pm.get_client.return_value = mqtt_mock
-
-    await auto_assign_spool(printer.id, 0, 0, spool, mock_pm, db_session)
-
-    mqtt_mock.extrusion_cali_sel.assert_called_once()
-    call_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
-    assert call_kwargs["cali_idx"] == 7  # stored profile, not 99

+ 23 - 228
backend/tests/unit/test_spoolbuddy_ssh.py → backend/tests/unit/services/test_spoolbuddy_ssh.py

@@ -223,30 +223,25 @@ async def test_run_ssh_command_success(tmp_path):
     mock_result.stderr = ""
     mock_result.exit_status = 0
 
-    mock_server_key = MagicMock()
-    mock_server_key.export_public_key.return_value = b"ssh-ed25519 AAAA test"
-
     mock_conn = AsyncMock()
     mock_conn.run = AsyncMock(return_value=mock_result)
-    mock_conn.get_server_host_key = MagicMock(return_value=mock_server_key)
     mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
     mock_conn.__aexit__ = AsyncMock(return_value=False)
 
     with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn) as mock_connect:
-        rc, stdout, stderr, observed_key = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
+        rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
 
     assert rc == 0
     assert stdout == "hello\n"
     assert stderr == ""
-    # TOFU mode (no known_hosts): returns observed key
-    assert observed_key == "ssh-ed25519 AAAA test"
     kwargs = mock_connect.call_args.kwargs
     assert kwargs["host"] == "10.0.0.1"
     assert kwargs["username"] == "spoolbuddy"
     assert kwargs["client_keys"] == [str(key_file)]
-    # TOFU default: known_hosts=None on first connect
+    # Host-key verification is disabled (equivalent to StrictHostKeyChecking=no)
     assert kwargs["known_hosts"] is None
-    # ~/.ssh/config loading is disabled — HOME may not resolve under arbitrary Docker PUIDs
+    # ~/.ssh/config loading is disabled — HOME may not resolve under arbitrary
+    # Docker PUIDs.
     assert kwargs["config"] == []
     mock_conn.run.assert_awaited_once()
     run_args = mock_conn.run.call_args
@@ -255,56 +250,13 @@ async def test_run_ssh_command_success(tmp_path):
     assert run_args.kwargs.get("check") is False
 
 
-@pytest.mark.asyncio
-async def test_run_ssh_command_with_known_hosts_skips_capture(tmp_path):
-    """When known_hosts is provided, observed_host_key must be None."""
-    import asyncssh
-
-    key_file = tmp_path / "key"
-    key_file.write_text("KEY")
-
-    mock_result = MagicMock()
-    mock_result.stdout = ""
-    mock_result.stderr = ""
-    mock_result.exit_status = 0
-
-    mock_conn = AsyncMock()
-    mock_conn.run = AsyncMock(return_value=mock_result)
-    mock_conn.get_server_host_key = MagicMock()
-    mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
-    mock_conn.__aexit__ = AsyncMock(return_value=False)
-
-    fake_kh = MagicMock(spec=asyncssh.SSHKnownHosts)
-    with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn):
-        rc, _, _, observed_key = await _run_ssh_command("10.0.0.1", "echo hi", key_file, known_hosts=fake_kh)
-
-    assert rc == 0
-    assert observed_key is None
-    mock_conn.get_server_host_key.assert_not_called()
-
-
-@pytest.mark.asyncio
-async def test_run_ssh_command_host_key_mismatch(tmp_path):
-    """HostKeyNotVerifiable must surface as rc=255 with a safe message (H1)."""
-    import asyncssh
-
-    key_file = tmp_path / "key"
-    key_file.write_text("KEY")
-
-    with patch(
-        "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
-        side_effect=asyncssh.HostKeyNotVerifiable(asyncssh.DISC_HOST_KEY_NOT_VERIFIABLE, "key mismatch"),
-    ):
-        rc, _, stderr, observed_key = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
-
-    assert rc == 255
-    assert "mismatch" in stderr.lower()
-    assert observed_key is None
-
-
 @pytest.mark.asyncio
 async def test_run_ssh_command_no_subprocess(tmp_path):
-    """Regression guard: _run_ssh_command must not spawn any subprocess."""
+    """Regression guard: _run_ssh_command must not spawn any subprocess.
+
+    The whole point of switching to asyncssh is to avoid `ssh`/`ssh-keygen`
+    calling getpwuid() inside Docker containers with arbitrary PUIDs.
+    """
     key_file = tmp_path / "key"
     key_file.write_text("KEY")
 
@@ -315,7 +267,6 @@ async def test_run_ssh_command_no_subprocess(tmp_path):
 
     mock_conn = AsyncMock()
     mock_conn.run = AsyncMock(return_value=mock_result)
-    mock_conn.get_server_host_key = MagicMock(return_value=None)
     mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
     mock_conn.__aexit__ = AsyncMock(return_value=False)
 
@@ -340,7 +291,7 @@ async def test_run_ssh_command_connection_failure(tmp_path):
         "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
         side_effect=asyncssh.Error(code=0, reason="Connection refused"),
     ):
-        rc, stdout, stderr, _ = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
+        rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
 
     assert rc == 255
     assert stdout == ""
@@ -357,7 +308,7 @@ async def test_run_ssh_command_os_error(tmp_path):
         "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
         side_effect=OSError("Network is unreachable"),
     ):
-        rc, _, stderr, _ = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
+        rc, _, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
 
     assert rc == 255
     assert "Network is unreachable" in stderr
@@ -369,6 +320,9 @@ async def test_run_ssh_command_timeout(tmp_path):
     key_file = tmp_path / "key"
     key_file.write_text("KEY")
 
+    # asyncssh.connect() returns a _ConnectionManager synchronously; the hang
+    # must happen inside __aenter__ so the surrounding asyncio.timeout can
+    # cancel it.
     mock_conn = AsyncMock()
 
     async def hang_enter():
@@ -378,7 +332,7 @@ async def test_run_ssh_command_timeout(tmp_path):
     mock_conn.__aexit__ = AsyncMock(return_value=False)
 
     with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn):
-        rc, _, stderr, _ = await _run_ssh_command("10.0.0.1", "sleep 999", key_file, timeout=0.05)
+        rc, _, stderr = await _run_ssh_command("10.0.0.1", "sleep 999", key_file, timeout=0.05)
 
     assert rc == -1
     assert "timed out" in stderr
@@ -393,7 +347,6 @@ def _make_update_mocks(tmp_path):
     mock_db_device.update_status = None
     mock_db_device.update_message = None
     mock_db_device.pending_command = None
-    mock_db_device.ssh_host_key = None  # TOFU: no stored key
 
     mock_result = MagicMock()
     mock_result.scalar_one_or_none.return_value = mock_db_device
@@ -422,9 +375,9 @@ async def test_perform_ssh_update_success(tmp_path):
 
     ssh_calls = []
 
-    async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
+    async def mock_ssh(ip, cmd, key, timeout=60):
         ssh_calls.append(cmd)
-        return 0, "ok", "", "ssh-ed25519 AAAA fakehostkey"
+        return 0, "ok", ""
 
     _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
 
@@ -432,7 +385,6 @@ async def test_perform_ssh_update_success(tmp_path):
         patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
         patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
         patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="dev"),
-        patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
         patch("backend.app.core.database.async_session", return_value=mock_ctx),
         patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
     ):
@@ -453,82 +405,6 @@ async def test_perform_ssh_update_success(tmp_path):
     assert mock_ws.broadcast.call_count >= 4
 
 
-@pytest.mark.asyncio
-async def test_perform_ssh_update_branch_is_shell_quoted(tmp_path):
-    """Branch name with shell-special chars must be quoted in all git commands (L1 fix)."""
-    import shlex
-
-    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
-    ssh_dir.mkdir(parents=True)
-    (ssh_dir / "id_ed25519").write_text("PRIVATE")
-    (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
-
-    # A branch name containing a semicolon — shell-injection without quoting
-    dangerous_branch = "dev; echo pwned"
-    safe_branch = shlex.quote(dangerous_branch)  # expected: "'dev; echo pwned'"
-
-    ssh_calls = []
-
-    async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
-        ssh_calls.append(cmd)
-        return 0, "ok", "", None
-
-    _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
-
-    with (
-        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
-        patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
-        patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value=dangerous_branch),
-        patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
-        patch("backend.app.core.database.async_session", return_value=mock_ctx),
-        patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
-    ):
-        mock_settings.base_dir = tmp_path
-        await perform_ssh_update("sb-test", "10.0.0.1")
-
-    # All git commands must use the shell-quoted form, never the raw dangerous string
-    git_cmds = [c for c in ssh_calls if "fetch" in c or "checkout" in c or "reset" in c]
-    for cmd in git_cmds:
-        assert safe_branch in cmd, f"Branch not shell-quoted in: {cmd}"
-        assert dangerous_branch not in cmd.replace(safe_branch, ""), f"Raw dangerous branch in: {cmd}"
-
-
-@pytest.mark.asyncio
-async def test_perform_ssh_update_tofu_stores_host_key(tmp_path):
-    """On first connect (no stored key), the observed host key must be persisted (H1)."""
-    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
-    ssh_dir.mkdir(parents=True)
-    (ssh_dir / "id_ed25519").write_text("PRIVATE")
-    (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
-
-    FAKE_HOST_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 fakehostkey"
-    call_count = 0
-
-    async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
-        nonlocal call_count
-        call_count += 1
-        # Only first call returns the observed host key (TOFU)
-        observed = FAKE_HOST_KEY if call_count == 1 else None
-        return 0, "ok", "", observed
-
-    mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
-    mock_device.ssh_host_key = None  # no stored key
-
-    with (
-        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
-        patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
-        patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
-        patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
-        patch("backend.app.core.database.async_session", return_value=mock_ctx),
-        patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
-    ):
-        mock_settings.base_dir = tmp_path
-        await perform_ssh_update("sb-test", "10.0.0.1")
-
-    # Device's ssh_host_key should have been set to the observed key
-    assert mock_device.ssh_host_key == FAKE_HOST_KEY
-
-
 @pytest.mark.asyncio
 async def test_perform_ssh_update_ssh_failure(tmp_path):
     """SSH connectivity check fails — should set error status."""
@@ -537,10 +413,10 @@ async def test_perform_ssh_update_ssh_failure(tmp_path):
     (ssh_dir / "id_ed25519").write_text("PRIVATE")
     (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
 
-    async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
+    async def mock_ssh(ip, cmd, key, timeout=60):
         if "echo ok" in cmd:
-            return 255, "", "Connection refused", None
-        return 0, "", "", None
+            return 255, "", "Connection refused"
+        return 0, "", ""
 
     mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
 
@@ -570,11 +446,11 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path):
 
     ssh_calls = []
 
-    async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
+    async def mock_ssh(ip, cmd, key, timeout=60):
         ssh_calls.append(cmd)
         if "fetch" in cmd:
-            return 1, "", "fatal: could not read from remote", None
-        return 0, "ok", "", None
+            return 1, "", "fatal: could not read from remote"
+        return 0, "ok", ""
 
     _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
 
@@ -582,7 +458,6 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path):
         patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
         patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
         patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
-        patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
         patch("backend.app.core.database.async_session", return_value=mock_ctx),
         patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
     ):
@@ -592,83 +467,3 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path):
     # Should stop after git fetch — no checkout, pip, restart
     assert len(ssh_calls) == 2  # echo ok + git fetch
     assert not any("checkout" in c for c in ssh_calls)
-
-
-@pytest.mark.asyncio
-async def test_perform_ssh_update_uses_stored_host_key(tmp_path):
-    """When device already has ssh_host_key set, all SSH calls must receive non-None known_hosts (Gap 1)."""
-    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
-    ssh_dir.mkdir(parents=True)
-    (ssh_dir / "id_ed25519").write_text("PRIVATE")
-    (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
-
-    STORED_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 storedkey"
-    SENTINEL_KNOWN_HOSTS = MagicMock(name="known_hosts_sentinel")
-    received_known_hosts = []
-
-    async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
-        received_known_hosts.append(known_hosts)
-        return 0, "ok", "", None  # no new observed key (already stored)
-
-    mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
-    mock_device.ssh_host_key = STORED_KEY
-
-    with (
-        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
-        patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
-        patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
-        patch(
-            "backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts",
-            return_value=SENTINEL_KNOWN_HOSTS,
-        ),
-        patch("backend.app.core.database.async_session", return_value=mock_ctx),
-        patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
-    ):
-        mock_settings.base_dir = tmp_path
-        await perform_ssh_update("sb-test", "10.0.0.1")
-
-    # Every SSH call must have received the sentinel known_hosts object (not None)
-    assert len(received_known_hosts) >= 2, "Expected at least 2 SSH calls"
-    for kh in received_known_hosts:
-        assert kh is SENTINEL_KNOWN_HOSTS, f"Expected sentinel known_hosts but got: {kh}"
-
-
-@pytest.mark.asyncio
-async def test_perform_ssh_update_corrupt_stored_key_falls_back_to_tofu(tmp_path):
-    """When stored ssh_host_key can't be parsed, update continues with known_hosts=None (Gap 2)."""
-    ssh_dir = tmp_path / "spoolbuddy" / "ssh"
-    ssh_dir.mkdir(parents=True)
-    (ssh_dir / "id_ed25519").write_text("PRIVATE")
-    (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
-
-    ssh_calls = []
-
-    async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
-        ssh_calls.append(cmd)
-        return 0, "ok", "", None
-
-    mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
-    mock_device.ssh_host_key = "THIS-IS-NOT-A-VALID-KEY"
-
-    with (
-        patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
-        patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
-        patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
-        patch(
-            "backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts",
-            side_effect=ValueError("Malformed key"),
-        ),
-        patch("backend.app.core.database.async_session", return_value=mock_ctx),
-        patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
-    ):
-        mock_settings.base_dir = tmp_path
-        # Must not raise — corrupt key degrades gracefully
-        await perform_ssh_update("sb-test", "10.0.0.1")
-
-    # Update must have completed all steps despite the corrupt key
-    assert any("echo ok" in c for c in ssh_calls)
-    assert any("fetch" in c for c in ssh_calls)
-    assert any("checkout" in c for c in ssh_calls)
-    # Broadcast must show success, not error
-    error_broadcasts = [c for c in mock_ws.broadcast.call_args_list if c[0][0].get("update_status") == "error"]
-    assert not error_broadcasts, f"Got unexpected error broadcast: {error_broadcasts}"

+ 17 - 244
backend/tests/unit/services/test_spoolman_service.py

@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, Mock, patch
 
 import pytest
 
-from backend.app.services.spoolman import AMSTray, SpoolmanClient, init_spoolman_client
+from backend.app.services.spoolman import AMSTray, SpoolmanClient
 
 
 class TestIsBambuLabSpool:
@@ -133,7 +133,7 @@ class TestSpoolmanClient:
             call_kwargs = mock_update.call_args.kwargs
             assert "remaining_weight" in call_kwargs
             assert call_kwargs["remaining_weight"] == 500.0  # 50% of 1000g
-            assert "location" not in call_kwargs
+            assert "location" in call_kwargs
 
     @pytest.mark.asyncio
     async def test_sync_ams_tray_skips_weight_when_disabled(self, client, sample_tray, existing_spool):
@@ -148,8 +148,9 @@ class TestSpoolmanClient:
             call_kwargs = mock_update.call_args.kwargs
             # remaining_weight should be None (not updated)
             assert call_kwargs.get("remaining_weight") is None
-            # location must never be written by Bambuddy — user-managed in Spoolman
-            assert "location" not in call_kwargs
+            # location should still be updated
+            assert "location" in call_kwargs
+            assert "TestPrinter" in call_kwargs["location"]
 
     @pytest.mark.asyncio
     async def test_sync_ams_tray_new_spool_always_includes_weight(self, client, sample_tray, mock_filament):
@@ -168,8 +169,8 @@ class TestSpoolmanClient:
             assert call_kwargs["remaining_weight"] == 500.0  # 50% of 1000g
 
     @pytest.mark.asyncio
-    async def test_sync_ams_tray_does_not_write_location(self, client, sample_tray, existing_spool):
-        """Verify sync_ams_tray never writes location= to Spoolman (user-managed field)."""
+    async def test_sync_ams_tray_location_format(self, client, sample_tray, existing_spool):
+        """Verify location format is correct when updating spool."""
         with (
             patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
             patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})) as mock_update,
@@ -177,125 +178,15 @@ class TestSpoolmanClient:
             await client.sync_ams_tray(sample_tray, "My Printer", disable_weight_sync=True)
 
             call_kwargs = mock_update.call_args.kwargs
-            # Bambuddy must never auto-set spool.location — it is user-managed in Spoolman
-            assert "location" not in call_kwargs
-
-    # ========================================================================
-    # T6: non-BL spool with custom RFID (H5 guard)
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    async def test_sync_ams_tray_non_bl_rfid_find_or_create_error_returns_none(self, client):
-        """Non-BL spool with custom RFID: find_or_create_filament failure returns None, not raises.
-
-        A third-party spool whose tag_uid is not exactly 16 hex chars is not
-        identified as BL. sync_ams_tray must catch find_or_create_filament
-        errors and return None instead of propagating the exception.
-        """
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        # 8-char tag → spool_tag is set, but is_bambu_lab_spool returns False
-        tray = AMSTray(
-            ams_id=0,
-            tray_id=2,
-            tray_type="PLA",
-            tray_sub_brands="eSun PLA+",
-            tray_color="00FF00FF",
-            remain=50,
-            tag_uid="AABB1234",
-            tray_uuid="",
-            tray_info_idx="",
-            tray_weight=1000,
-        )
-
-        with (
-            patch.object(client, "find_spool_by_tag", AsyncMock(return_value=None)),
-            patch.object(
-                client,
-                "find_or_create_filament",
-                AsyncMock(side_effect=SpoolmanUnavailableError("timeout")),
-            ),
-        ):
-            result = await client.sync_ams_tray(tray, "TestPrinter")
-
-        assert result is None
-
-    # ========================================================================
-    # T7: hint path uncached — get_spool(hint) called when not in cached_spools
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    async def test_sync_ams_tray_hint_uncached_calls_get_spool(self, client):
-        """No-RFID path: when hint spool is absent from cached_spools, get_spool is called."""
-        tray = AMSTray(
-            ams_id=0,
-            tray_id=3,
-            tray_type="PETG",
-            tray_sub_brands="Generic PETG",
-            tray_color="0000FFFF",
-            remain=75,
-            tag_uid="",
-            tray_uuid="",
-            tray_info_idx="",
-            tray_weight=1000,
-        )
-        # cached_spools exists but does NOT contain spool 99
-        cached_spools = [{"id": 1, "extra": {}}]
-        fetched_spool = {"id": 99, "extra": {}}
-
-        with (
-            patch.object(client, "get_spool", AsyncMock(return_value=fetched_spool)) as mock_get,
-            patch.object(client, "update_spool", AsyncMock(return_value=fetched_spool)),
-        ):
-            result = await client.sync_ams_tray(
-                tray,
-                "TestPrinter",
-                cached_spools=cached_spools,
-                spoolman_spool_id_hint=99,
-            )
-
-        assert result is not None
-        mock_get.assert_awaited_once_with(99)
-
-    # ========================================================================
-    # T8: hint ignored when RFID tag is present
-    # ========================================================================
-
-    @pytest.mark.asyncio
-    async def test_sync_ams_tray_rfid_takes_precedence_over_hint(self, client, existing_spool):
-        """When tray_uuid is set, the RFID path is used and the hint is never consulted."""
-        tray = AMSTray(
-            ams_id=0,
-            tray_id=4,
-            tray_type="PLA",
-            tray_sub_brands="PLA Basic",
-            tray_color="FF0000FF",
-            remain=50,
-            tag_uid="",
-            tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
-            tray_info_idx="GFA00",
-            tray_weight=1000,
-        )
-
-        with (
-            patch.object(client, "find_spool_by_tag", AsyncMock(return_value=existing_spool)),
-            patch.object(client, "update_spool", AsyncMock(return_value={"id": 42})),
-            patch.object(client, "get_spool", AsyncMock()) as mock_get_spool,
-        ):
-            result = await client.sync_ams_tray(
-                tray,
-                "TestPrinter",
-                spoolman_spool_id_hint=99,
-            )
-
-        assert result is not None
-        # hint path (get_spool) must NOT be called when RFID is present
-        mock_get_spool.assert_not_called()
+            # Location should follow pattern: "PrinterName - AMS A1"
+            assert "location" in call_kwargs
+            assert "My Printer" in call_kwargs["location"]
+            assert "AMS" in call_kwargs["location"]
 
     @pytest.mark.asyncio
-    async def test_sync_ams_tray_non_bambu_no_rfid_returns_none(self, client):
-        """Third-party spool without any RFID and no hint returns None."""
-        # Non-BL spool: no tray_uuid, no tag_uid, no spoolman_spool_id_hint → nothing to match
+    async def test_sync_ams_tray_skips_non_bambu_spool(self, client):
+        """Verify non-Bambu Lab spools are skipped."""
+        # Third-party spool without proper identifiers
         tray = AMSTray(
             ams_id=0,
             tray_id=0,
@@ -305,42 +196,13 @@ class TestSpoolmanClient:
             remain=50,
             tag_uid="",
             tray_uuid="",
-            tray_info_idx="",
+            tray_info_idx="",  # No Bambu Lab preset ID
             tray_weight=1000,
         )
 
         result = await client.sync_ams_tray(tray, "TestPrinter")
         assert result is None
 
-    @pytest.mark.asyncio
-    async def test_sync_ams_tray_hint_updates_spool_without_rfid(self, client):
-        """No-RFID fallback: spool_id_hint from local slot-assignment table updates the spool."""
-        tray = AMSTray(
-            ams_id=0,
-            tray_id=0,
-            tray_type="PLA",
-            tray_sub_brands="Generic PLA",
-            tray_color="00FF00FF",
-            remain=80,
-            tag_uid="",
-            tray_uuid="",
-            tray_info_idx="",
-            tray_weight=1000,
-        )
-        cached_spools = [{"id": 99, "extra": {}}]
-
-        with patch.object(client, "update_spool", new_callable=AsyncMock) as mock_update:
-            mock_update.return_value = {"id": 99}
-            result = await client.sync_ams_tray(
-                tray, "TestPrinter", cached_spools=cached_spools, spoolman_spool_id_hint=99
-            )
-
-        assert result is not None
-        assert result["id"] == 99
-        mock_update.assert_called_once()
-        call_kwargs = mock_update.call_args.kwargs
-        assert "location" not in call_kwargs
-
     @pytest.mark.asyncio
     async def test_sync_ams_tray_weight_calculation(self, client, existing_spool):
         """Verify remaining weight is calculated correctly for various percentages."""
@@ -523,11 +385,9 @@ class TestSpoolmanClient:
 
     @pytest.mark.asyncio
     async def test_get_spools_raises_after_3_failed_attempts(self, client):
-        """Verify get_spools raises SpoolmanUnavailableError after 3 failed connection attempts."""
+        """Verify get_spools raises exception after 3 failed attempts."""
         import httpx
 
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
         with (
             patch.object(client, "_get_client", AsyncMock()) as mock_get_client,
             patch.object(client, "close", AsyncMock()) as mock_close,
@@ -539,7 +399,7 @@ class TestSpoolmanClient:
             # All 3 attempts fail
             mock_http_client.get.side_effect = httpx.ReadError("Connection closed")
 
-            with pytest.raises(SpoolmanUnavailableError):
+            with pytest.raises(httpx.ReadError):
                 await client.get_spools()
 
             assert mock_get_client.call_count == 3
@@ -584,90 +444,3 @@ class TestSpoolmanClient:
             mock_close.assert_not_called()
             # Should sleep once (after first failed attempt)
             assert mock_sleep.call_count == 1
-
-
-# ---------------------------------------------------------------------------
-# init_spoolman_client — SSRF guard (B4 / T3)
-# ---------------------------------------------------------------------------
-
-
-class TestInitSpoolmanClientSSRFGuard:
-    """init_spoolman_client must reject genuinely unsafe URLs before creating a client.
-
-    Scope: cloud metadata endpoints, multicast, unspecified, non-http(s) schemes,
-    and numeric-encoded IP bypasses. Loopback and RFC-1918 private ranges are
-    explicitly allowed — Bambuddy's primary deployment is LAN-local Spoolman.
-    """
-
-    @pytest.mark.asyncio
-    async def test_cloud_metadata_raises_value_error(self):
-        with pytest.raises(ValueError, match="cloud metadata"):
-            await init_spoolman_client("http://169.254.169.254/latest/meta-data/")
-
-    @pytest.mark.asyncio
-    async def test_multicast_raises_value_error(self):
-        with pytest.raises(ValueError, match="multicast|unspecified"):
-            await init_spoolman_client("http://224.0.0.1/")
-
-    @pytest.mark.asyncio
-    async def test_unspecified_raises_value_error(self):
-        with pytest.raises(ValueError, match="multicast|unspecified"):
-            await init_spoolman_client("http://0.0.0.0/")
-
-    @pytest.mark.asyncio
-    async def test_numeric_encoded_ip_raises_value_error(self):
-        # decimal-encoded 127.0.0.1 — libc resolves these but ipaddress doesn't
-        with pytest.raises(ValueError, match="numeric-encoded"):
-            await init_spoolman_client("http://2130706433/")
-
-    @pytest.mark.asyncio
-    async def test_non_http_scheme_raises_value_error(self):
-        with pytest.raises(ValueError, match="http or https"):
-            await init_spoolman_client("file:///etc/passwd")
-
-    @pytest.mark.asyncio
-    async def test_private_ip_is_allowed(self):
-        """Regression: RFC-1918 private addresses are the normal LAN topology."""
-        mock_instance = AsyncMock()
-        with (
-            patch("backend.app.services.spoolman._spoolman_client", None),
-            patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
-        ):
-            client = await init_spoolman_client("http://192.168.1.50:7912/")
-        mock_cls.assert_called_once_with("http://192.168.1.50:7912/")
-        assert client is mock_instance
-
-    @pytest.mark.asyncio
-    async def test_loopback_ip_is_allowed(self):
-        """Regression: same-host Spoolman via loopback is a supported topology."""
-        mock_instance = AsyncMock()
-        with (
-            patch("backend.app.services.spoolman._spoolman_client", None),
-            patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
-        ):
-            client = await init_spoolman_client("http://127.0.0.1:7912/")
-        mock_cls.assert_called_once_with("http://127.0.0.1:7912/")
-        assert client is mock_instance
-
-    @pytest.mark.asyncio
-    async def test_localhost_hostname_is_allowed(self):
-        # localhost (hostname, not bare IP) is a supported topology for same-host Spoolman
-        mock_instance = AsyncMock()
-        with (
-            patch("backend.app.services.spoolman._spoolman_client", None),
-            patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
-        ):
-            client = await init_spoolman_client("http://localhost:7912/")
-        mock_cls.assert_called_once_with("http://localhost:7912/")
-        assert client is mock_instance
-
-    @pytest.mark.asyncio
-    async def test_public_url_is_allowed(self):
-        mock_instance = AsyncMock()
-        with (
-            patch("backend.app.services.spoolman._spoolman_client", None),
-            patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls,
-        ):
-            client = await init_spoolman_client("http://spoolman.example.com:7912/")
-        mock_cls.assert_called_once_with("http://spoolman.example.com:7912/")
-        assert client is mock_instance

+ 3 - 7
backend/tests/unit/services/test_spoolman_tracking.py

@@ -19,15 +19,15 @@ from backend.app.services.spoolman_tracking import (
 class TestResolveSpoolTag:
     """Tests for _resolve_spool_tag()."""
 
-    def test_prefers_tray_uuid_over_tag_uid(self):
+    def test_prefers_tray_uuid(self):
         tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": "DEADBEEF"}
         assert _resolve_spool_tag(tray) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
 
-    def test_falls_back_to_tag_uid_when_no_uuid(self):
+    def test_falls_back_to_tag_uid(self):
         tray = {"tray_uuid": "", "tag_uid": "DEADBEEF"}
         assert _resolve_spool_tag(tray) == "DEADBEEF"
 
-    def test_falls_back_to_tag_uid_when_uuid_zero(self):
+    def test_skips_zero_uuid(self):
         tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"}
         assert _resolve_spool_tag(tray) == "DEADBEEF"
 
@@ -45,10 +45,6 @@ class TestResolveSpoolTag:
         # global_tray_id 5 -> ams_id 1, tray_id 1
         assert _resolve_spool_tag(tray, "01P00A000000000", 5) == "ABA7845700010001"
 
-    def test_prefers_tray_uuid_over_fallback_when_non_zero(self):
-        tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": ""}
-        assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
-
     def test_empty_both(self):
         tray = {"tray_uuid": "", "tag_uid": ""}
         assert _resolve_spool_tag(tray) == ""

+ 0 - 152
backend/tests/unit/test_db_dialect.py

@@ -544,158 +544,6 @@ class TestSafeExecutePattern:
         assert "printer_ids" in sql
 
 
-class TestSpoolmanTableDialect:
-    """Phase 1: active_print_spoolman and spool_usage_history use dialect-correct DDL.
-
-    These tables were created with raw 'INTEGER PRIMARY KEY AUTOINCREMENT' (SQLite-only
-    syntax) before the fix.  Now they branch on is_sqlite() exactly like
-    smart_plug_energy_snapshots.
-    """
-
-    @pytest.mark.asyncio
-    async def test_active_print_spoolman_sqlite_creates_table(self):
-        """SQLite: active_print_spoolman is created with valid SQLite DDL."""
-        from sqlalchemy import text
-        from sqlalchemy.ext.asyncio import create_async_engine
-
-        from backend.app.core.database import _safe_execute
-
-        sql = """
-        CREATE TABLE IF NOT EXISTS active_print_spoolman (
-            id INTEGER PRIMARY KEY AUTOINCREMENT,
-            printer_id INTEGER NOT NULL,
-            archive_id INTEGER NOT NULL,
-            filament_usage TEXT NOT NULL,
-            ams_trays TEXT NOT NULL,
-            slot_to_tray TEXT,
-            layer_usage TEXT,
-            filament_properties TEXT,
-            UNIQUE(printer_id, archive_id)
-        )
-        """
-        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
-        async with engine.begin() as conn:
-            await _safe_execute(conn, sql)
-            result = await conn.execute(
-                text("SELECT name FROM sqlite_master WHERE type='table' AND name='active_print_spoolman'")
-            )
-            assert result.fetchone() is not None, "Table must be created on SQLite"
-        await engine.dispose()
-
-    @pytest.mark.asyncio
-    async def test_active_print_spoolman_postgres_sql_uses_serial(self):
-        """PostgreSQL: active_print_spoolman SQL uses SERIAL PRIMARY KEY, not AUTOINCREMENT."""
-        from unittest.mock import AsyncMock, MagicMock
-
-        from backend.app.core.database import _safe_execute
-
-        captured_sql: list[str] = []
-
-        nested_cm = MagicMock()
-        nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
-        nested_cm.__aexit__ = AsyncMock(return_value=False)
-
-        async def capturing_execute(sql_or_text, *args, **kwargs):
-            captured_sql.append(str(sql_or_text))
-
-        nested_cm.execute = AsyncMock(side_effect=capturing_execute)
-        mock_conn = MagicMock()
-        mock_conn.begin_nested.return_value = nested_cm
-        mock_conn.execute = AsyncMock(side_effect=capturing_execute)
-
-        # PG path SQL — same string as in run_migrations() when is_sqlite() is False
-        pg_sql = """
-        CREATE TABLE IF NOT EXISTS active_print_spoolman (
-            id SERIAL PRIMARY KEY,
-            printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
-            filament_usage TEXT NOT NULL,
-            ams_trays TEXT NOT NULL,
-            slot_to_tray TEXT,
-            layer_usage TEXT,
-            filament_properties TEXT,
-            UNIQUE(printer_id, archive_id)
-        )
-        """
-        await _safe_execute(mock_conn, pg_sql)
-
-        assert captured_sql, "execute must have been called"
-        combined = " ".join(captured_sql)
-        assert "SERIAL PRIMARY KEY" in combined
-        assert "AUTOINCREMENT" not in combined
-
-    @pytest.mark.asyncio
-    async def test_spool_usage_history_sqlite_creates_table(self):
-        """SQLite: spool_usage_history is created with valid SQLite DDL."""
-        from sqlalchemy import text
-        from sqlalchemy.ext.asyncio import create_async_engine
-
-        from backend.app.core.database import _safe_execute
-
-        sql = """
-        CREATE TABLE IF NOT EXISTS spool_usage_history (
-            id INTEGER PRIMARY KEY AUTOINCREMENT,
-            spool_id INTEGER NOT NULL,
-            printer_id INTEGER,
-            print_name VARCHAR(500),
-            weight_used REAL NOT NULL DEFAULT 0,
-            percent_used INTEGER NOT NULL DEFAULT 0,
-            status VARCHAR(20) NOT NULL DEFAULT 'completed',
-            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
-        )
-        """
-        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
-        async with engine.begin() as conn:
-            await _safe_execute(conn, sql)
-            result = await conn.execute(
-                text("SELECT name FROM sqlite_master WHERE type='table' AND name='spool_usage_history'")
-            )
-            assert result.fetchone() is not None, "Table must be created on SQLite"
-        await engine.dispose()
-
-    @pytest.mark.asyncio
-    async def test_spool_usage_history_postgres_sql_uses_serial_and_timestamp(self):
-        """PostgreSQL: spool_usage_history SQL uses SERIAL and TIMESTAMP, not AUTOINCREMENT/DATETIME."""
-        from unittest.mock import AsyncMock, MagicMock
-
-        from backend.app.core.database import _safe_execute
-
-        captured_sql: list[str] = []
-
-        nested_cm = MagicMock()
-        nested_cm.__aenter__ = AsyncMock(return_value=nested_cm)
-        nested_cm.__aexit__ = AsyncMock(return_value=False)
-
-        async def capturing_execute(sql_or_text, *args, **kwargs):
-            captured_sql.append(str(sql_or_text))
-
-        nested_cm.execute = AsyncMock(side_effect=capturing_execute)
-        mock_conn = MagicMock()
-        mock_conn.begin_nested.return_value = nested_cm
-        mock_conn.execute = AsyncMock(side_effect=capturing_execute)
-
-        pg_sql = """
-        CREATE TABLE IF NOT EXISTS spool_usage_history (
-            id SERIAL PRIMARY KEY,
-            spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
-            printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
-            print_name VARCHAR(500),
-            weight_used REAL NOT NULL DEFAULT 0,
-            percent_used INTEGER NOT NULL DEFAULT 0,
-            status VARCHAR(20) NOT NULL DEFAULT 'completed',
-            created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
-        )
-        """
-        await _safe_execute(mock_conn, pg_sql)
-
-        assert captured_sql, "execute must have been called"
-        combined = " ".join(captured_sql)
-        assert "SERIAL PRIMARY KEY" in combined
-        assert "TIMESTAMP" in combined
-        assert "AUTOINCREMENT" not in combined
-        assert "DATETIME" not in combined
-
-
 class TestAutoLinkConstraintMigration:
     """Tests for _migrate_update_auto_link_constraint (Fall C / Azure support)."""
 

+ 0 - 179
backend/tests/unit/test_opentag3d.py

@@ -7,9 +7,7 @@ from backend.app.services.opentag3d import (
     OPENTAG3D_MIME_TYPE,
     PAYLOAD_SIZE,
     _build_payload,
-    _build_payload_from_dict,
     encode_opentag3d,
-    encode_opentag3d_from_mapped,
 )
 
 
@@ -142,180 +140,3 @@ class TestEncodeOpentag3d:
         data = encode_opentag3d(_make_spool())
         ntag213_capacity = 36 * 4  # 144 bytes
         assert len(data) <= ntag213_capacity
-
-
-# ---------------------------------------------------------------------------
-# _build_payload_from_dict / encode_opentag3d_from_mapped
-# ---------------------------------------------------------------------------
-
-MINIMAL_MAPPED = {
-    "material": "PLA",
-    "subtype": "Basic",
-    "brand": "Bambu Lab",
-    "color_name": None,
-    "rgba": "FF0000FF",
-    "label_weight": 1000,
-    "nozzle_temp_min": None,
-}
-
-
-class TestBuildPayloadFromDict:
-    def test_payload_is_102_bytes(self):
-        assert len(_build_payload_from_dict(MINIMAL_MAPPED)) == PAYLOAD_SIZE
-
-    def test_material_encoded(self):
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "material": "PETG"})
-        assert payload[0x02:0x07].decode("utf-8") == "PETG "
-
-    def test_subtype_encoded(self):
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "subtype": "Silk"})
-        assert payload[0x07:0x0C].decode("utf-8") == "Silk "
-
-    def test_brand_encoded(self):
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "brand": "Polymaker"})
-        assert payload[0x1B:0x2B].decode("utf-8") == "Polymaker       "
-
-    def test_rgba_encoded(self):
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "rgba": "00FF00FF"})
-        assert payload[0x4B:0x4F] == bytes([0x00, 0xFF, 0x00, 0xFF])
-
-    def test_label_weight_encoded(self):
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 750})
-        weight = struct.unpack_from(">H", payload, 0x5E)[0]
-        assert weight == 750
-
-    def test_none_color_name_zero_filled(self):
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "color_name": None})
-        assert payload[0x2B:0x4B] == b"                                "
-
-    def test_missing_keys_produce_safe_defaults(self):
-        payload = _build_payload_from_dict({})
-        assert len(payload) == PAYLOAD_SIZE
-        assert payload[0x02:0x07] == b"     "
-        weight = struct.unpack_from(">H", payload, 0x5E)[0]
-        assert weight == 0
-
-    def test_label_weight_overflow_clamped_to_65535(self):
-        """label_weight > 65535 must not raise struct.error (uint16 overflow)."""
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 70000})
-        weight = struct.unpack_from(">H", payload, 0x5E)[0]
-        assert weight == 65535
-
-    def test_label_weight_negative_clamped_to_zero(self):
-        """Negative label_weight must be clamped to 0, not raise struct.error."""
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": -1})
-        weight = struct.unpack_from(">H", payload, 0x5E)[0]
-        assert weight == 0
-
-    def test_label_weight_at_uint16_max_accepted(self):
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 65535})
-        weight = struct.unpack_from(">H", payload, 0x5E)[0]
-        assert weight == 65535
-
-    def test_nozzle_temp_float_does_not_crash(self):
-        """nozzle_temp_min as float (e.g. 220.5) must not raise TypeError."""
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": 220.5})
-        assert payload[0x60] == 44  # int(220.5 // 5) = 44
-
-    def test_nozzle_temp_overflow_clamped_to_255(self):
-        """nozzle_temp_min causing byte > 255 must be clamped, not raise ValueError."""
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": 1280})
-        assert payload[0x60] == 255  # 1280 // 5 = 256 → clamped to 255
-
-    def test_nozzle_temp_negative_clamped_to_zero(self):
-        payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": -50})
-        assert payload[0x60] == 0
-
-    def test_matches_orm_path_for_same_data(self):
-        """_build_payload_from_dict must produce identical bytes to _build_payload."""
-        spool = _make_spool(
-            material="PLA",
-            subtype="Matte",
-            brand="Polymaker",
-            color_name="Jade White",
-            rgba="00AE42FF",
-            label_weight=1000,
-            nozzle_temp_min=220,
-        )
-        orm_payload = _build_payload(spool)
-        dict_payload = _build_payload_from_dict(
-            {
-                "material": "PLA",
-                "subtype": "Matte",
-                "brand": "Polymaker",
-                "color_name": "Jade White",
-                "rgba": "00AE42FF",
-                "label_weight": 1000,
-                "nozzle_temp_min": 220,
-            }
-        )
-        assert orm_payload == dict_payload
-
-
-class TestEncodeOpentag3dFromMapped:
-    def test_total_size(self):
-        data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert len(data) == 133
-
-    def test_starts_with_cc(self):
-        data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
-
-    def test_ends_with_terminator(self):
-        data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert data[-1] == 0xFE
-
-    def test_mime_type_present(self):
-        data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert b"application/opentag3d" in data
-
-    def test_fits_ntag213(self):
-        data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert len(data) <= 36 * 4  # 144 bytes
-
-    def test_identical_output_to_orm_path(self):
-        """encode_opentag3d_from_mapped must produce the same bytes as encode_opentag3d."""
-        spool = _make_spool(
-            material="PLA",
-            subtype="Matte",
-            brand="Polymaker",
-            color_name="Jade White",
-            rgba="00AE42FF",
-            label_weight=1000,
-            nozzle_temp_min=220,
-        )
-        orm_bytes = encode_opentag3d(spool)
-        mapped_bytes = encode_opentag3d_from_mapped(
-            {
-                "material": "PLA",
-                "subtype": "Matte",
-                "brand": "Polymaker",
-                "color_name": "Jade White",
-                "rgba": "00AE42FF",
-                "label_weight": 1000,
-                "nozzle_temp_min": 220,
-            }
-        )
-        assert orm_bytes == mapped_bytes
-
-    def test_spoolman_mapped_dict_accepted(self):
-        """Accepts the exact dict shape produced by _map_spoolman_spool."""
-        from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
-
-        raw = {
-            "id": 7,
-            "filament": {
-                "material": "PETG",
-                "name": "PETG Basic",
-                "color_hex": "00FF00",
-                "weight": 1000.0,
-                "vendor": {"name": "Bambu Lab"},
-            },
-            "used_weight": 100.0,
-            "archived": False,
-            "registered": "2024-01-01T00:00:00Z",
-        }
-        mapped = _map_spoolman_spool(raw)
-        data = encode_opentag3d_from_mapped(mapped)
-        assert len(data) == 133
-        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])

+ 0 - 207
backend/tests/unit/test_spoolbuddy_schema_validation.py

@@ -1,207 +0,0 @@
-"""Unit tests for SpoolBuddy schema validation (security fixes H2, M2, M4).
-
-Tests Pydantic model validation without requiring a running server or DB.
-"""
-
-import pytest
-from pydantic import ValidationError
-
-from backend.app.schemas.spoolbuddy import (
-    DeviceRegisterRequest,
-    HeartbeatRequest,
-    ScaleReadingRequest,
-    UpdateStatusRequest,
-    WriteTagResultRequest,
-)
-
-# ---------------------------------------------------------------------------
-# H2 — UpdateStatusRequest: only valid Literal values accepted
-# ---------------------------------------------------------------------------
-
-
-class TestUpdateStatusRequestValidation:
-    def test_valid_status_updating(self):
-        req = UpdateStatusRequest(status="updating")
-        assert req.status == "updating"
-
-    def test_valid_status_complete(self):
-        req = UpdateStatusRequest(status="complete")
-        assert req.status == "complete"
-
-    def test_valid_status_error(self):
-        req = UpdateStatusRequest(status="error")
-        assert req.status == "error"
-
-    def test_invalid_status_rejected(self):
-        """Arbitrary status strings must be rejected (H2: prevents unbounded WS injection)."""
-        with pytest.raises(ValidationError):
-            UpdateStatusRequest(status="hacked")
-
-    def test_empty_status_rejected(self):
-        with pytest.raises(ValidationError):
-            UpdateStatusRequest(status="")
-
-    def test_message_max_length_enforced(self):
-        """message field must not exceed 255 chars."""
-        with pytest.raises(ValidationError):
-            UpdateStatusRequest(status="updating", message="x" * 256)
-
-    def test_message_at_max_length_accepted(self):
-        req = UpdateStatusRequest(status="complete", message="x" * 255)
-        assert len(req.message) == 255
-
-
-# ---------------------------------------------------------------------------
-# M2 — HeartbeatRequest: system_stats size limit (4096 bytes)
-# ---------------------------------------------------------------------------
-
-
-class TestHeartbeatSystemStatsValidation:
-    def test_none_accepted(self):
-        req = HeartbeatRequest(system_stats=None)
-        assert req.system_stats is None
-
-    def test_small_dict_accepted(self):
-        req = HeartbeatRequest(system_stats={"cpu": 12.5, "mem": 60.0})
-        assert req.system_stats["cpu"] == 12.5
-
-    def test_oversized_dict_rejected(self):
-        """system_stats exceeding 4096 bytes JSON-encoded must be rejected (M2)."""
-        huge = {"data": "x" * 5000}
-        with pytest.raises(ValidationError, match="4096"):
-            HeartbeatRequest(system_stats=huge)
-
-    def test_exactly_4096_bytes_accepted(self):
-        """A dict whose JSON is exactly 4096 bytes must pass."""
-        import json
-
-        # Build a dict whose JSON is exactly 4096 bytes
-        filler = "x" * (4096 - len('{"k": ""}'))
-        d = {"k": filler}
-        assert len(json.dumps(d)) == 4096
-        req = HeartbeatRequest(system_stats=d)
-        assert req.system_stats is not None
-
-    def test_one_byte_over_limit_rejected(self):
-        import json
-
-        filler = "x" * (4097 - len('{"k": ""}'))
-        d = {"k": filler}
-        assert len(json.dumps(d)) == 4097
-        with pytest.raises(ValidationError):
-            HeartbeatRequest(system_stats=d)
-
-
-# ---------------------------------------------------------------------------
-# M4 — DeviceRegisterRequest: max_length on device-sourced string fields
-# ---------------------------------------------------------------------------
-
-
-class TestDeviceRegisterRequestValidation:
-    VALID_BASE = {"device_id": "dev1", "hostname": "spoolbuddy.local", "ip_address": "192.168.1.50"}
-
-    def test_valid_minimal_accepted(self):
-        req = DeviceRegisterRequest(**self.VALID_BASE)
-        assert req.device_id == "dev1"
-
-    def test_firmware_version_too_long_rejected(self):
-        with pytest.raises(ValidationError):
-            DeviceRegisterRequest(**self.VALID_BASE, firmware_version="x" * 21)
-
-    def test_firmware_version_at_max_accepted(self):
-        req = DeviceRegisterRequest(**self.VALID_BASE, firmware_version="x" * 20)
-        assert req.firmware_version == "x" * 20
-
-    def test_nfc_reader_type_too_long_rejected(self):
-        with pytest.raises(ValidationError):
-            DeviceRegisterRequest(**self.VALID_BASE, nfc_reader_type="x" * 21)
-
-    def test_nfc_connection_too_long_rejected(self):
-        with pytest.raises(ValidationError):
-            DeviceRegisterRequest(**self.VALID_BASE, nfc_connection="x" * 21)
-
-    def test_backend_url_too_long_rejected(self):
-        with pytest.raises(ValidationError):
-            DeviceRegisterRequest(**self.VALID_BASE, backend_url="http://" + "x" * 249)
-
-    def test_backend_url_at_max_accepted(self):
-        url = "http://" + "x" * (255 - len("http://"))
-        req = DeviceRegisterRequest(**self.VALID_BASE, backend_url=url)
-        assert req.backend_url == url
-
-    def test_device_id_too_long_rejected(self):
-        with pytest.raises(ValidationError):
-            DeviceRegisterRequest(device_id="x" * 51, hostname="h", ip_address="1.2.3.4")
-
-
-# ---------------------------------------------------------------------------
-# M4 — WriteTagResultRequest: device_id max_length
-# ---------------------------------------------------------------------------
-
-
-class TestWriteTagResultRequestValidation:
-    def test_device_id_too_long_rejected(self):
-        with pytest.raises(ValidationError):
-            WriteTagResultRequest(device_id="x" * 51, spool_id=1, tag_uid="AABBCCDD", success=True)
-
-    def test_device_id_at_max_accepted(self):
-        req = WriteTagResultRequest(device_id="x" * 50, spool_id=1, tag_uid="AABBCCDD", success=True)
-        assert len(req.device_id) == 50
-
-    def test_tag_uid_hex_pattern_accepted(self):
-        req = WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABBCCDD", success=True)
-        assert req.tag_uid == "AABBCCDD"
-
-    def test_tag_uid_non_hex_rejected(self):
-        """Non-hex characters in tag_uid must be rejected (prevents injection via NFC write-back)."""
-        with pytest.raises(ValidationError):
-            WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABB; DROP", success=True)
-
-    def test_tag_uid_too_short_rejected(self):
-        with pytest.raises(ValidationError):
-            WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABB", success=True)
-
-    def test_tag_uid_max_length_accepted(self):
-        req = WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="A" * 30, success=True)
-        assert len(req.tag_uid) == 30
-
-    def test_tag_uid_over_max_length_rejected(self):
-        with pytest.raises(ValidationError):
-            WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="A" * 31, success=True)
-
-
-# ---------------------------------------------------------------------------
-# M4 — ScaleReadingRequest: weight_grams accepts any float (raw uncalibrated ADC)
-# ---------------------------------------------------------------------------
-
-
-class TestScaleReadingRequestValidation:
-    def test_valid_weight_accepted(self):
-        req = ScaleReadingRequest(device_id="sb1", weight_grams=250.0)
-        assert req.weight_grams == 250.0
-
-    def test_zero_weight_accepted(self):
-        req = ScaleReadingRequest(device_id="sb1", weight_grams=0.0)
-        assert req.weight_grams == 0.0
-
-    def test_large_raw_adc_weight_accepted(self):
-        # Uncalibrated scale with factor=1.0 produces raw ADC values in the millions
-        req = ScaleReadingRequest(device_id="sb1", weight_grams=5_000_000.0)
-        assert req.weight_grams == 5_000_000.0
-
-    def test_negative_weight_accepted(self):
-        # Scale can legitimately read negative values when tare is not calibrated
-        req = ScaleReadingRequest(device_id="sb1", weight_grams=-50_000.0)
-        assert req.weight_grams == -50_000.0
-
-    def test_nan_weight_rejected(self):
-        import math
-
-        with pytest.raises(ValidationError):
-            ScaleReadingRequest(device_id="sb1", weight_grams=math.nan)
-
-    def test_inf_weight_rejected(self):
-        import math
-
-        with pytest.raises(ValidationError):
-            ScaleReadingRequest(device_id="sb1", weight_grams=math.inf)

+ 0 - 58
backend/tests/unit/test_spoolman_extra_lock.py

@@ -1,58 +0,0 @@
-"""T-Gap 6: WeakValueDictionary lock concurrency tests for merge_spool_extra."""
-
-import asyncio
-
-import pytest
-
-from backend.app.services.spoolman import SpoolmanClient
-
-
-class TestExtraLock:
-    """Verify extra_lock uses WeakValueDictionary and same object is returned within scope."""
-
-    def test_extra_lock_same_instance_within_scope(self):
-        """Two calls with the same spool_id return the same lock object."""
-        client = SpoolmanClient("http://localhost:7912")
-        lock_a = client.extra_lock(1)
-        lock_b = client.extra_lock(1)
-        assert lock_a is lock_b
-
-    def test_extra_lock_different_ids_different_instances(self):
-        """Different spool IDs return different locks."""
-        client = SpoolmanClient("http://localhost:7912")
-        lock_1 = client.extra_lock(1)
-        lock_2 = client.extra_lock(2)
-        assert lock_1 is not lock_2
-        # Keep references alive
-        _ = lock_1, lock_2
-
-    def test_extra_lock_released_when_no_reference(self):
-        """Lock is garbage-collected once no reference is held (WeakValueDictionary)."""
-        import gc
-        import weakref
-
-        client = SpoolmanClient("http://localhost:7912")
-        lock = client.extra_lock(42)
-        ref = weakref.ref(lock)
-        del lock
-        gc.collect()
-        # Lock should have been evicted from the WeakValueDictionary
-        assert ref() is None
-        assert 42 not in client._extra_locks
-
-    @pytest.mark.asyncio
-    async def test_concurrent_calls_serialized(self):
-        """Concurrent calls to extra_lock with same spool_id are serialized."""
-        client = SpoolmanClient("http://localhost:7912")
-        results = []
-
-        async def hold_lock():
-            lock = client.extra_lock(99)
-            async with lock:
-                results.append("enter")
-                await asyncio.sleep(0.01)
-                results.append("exit")
-
-        await asyncio.gather(hold_lock(), hold_lock())
-        # enter/exit pairs must not interleave
-        assert results == ["enter", "exit", "enter", "exit"]

+ 0 - 433
backend/tests/unit/test_spoolman_inventory_helpers.py

@@ -1,433 +0,0 @@
-"""Unit tests for _safe_int, _safe_float, and _map_spoolman_spool helpers."""
-
-import math
-
-import pytest
-
-from backend.app.api.routes._spoolman_helpers import (
-    _map_spoolman_spool,
-    _safe_float,
-    _safe_int,
-)
-
-# ---------------------------------------------------------------------------
-# _safe_int
-# ---------------------------------------------------------------------------
-
-
-class TestSafeInt:
-    def test_normal_int(self):
-        assert _safe_int(1000, 0) == 1000
-
-    def test_float_rounds_down(self):
-        assert _safe_int(750.9, 0) == 750
-
-    def test_none_returns_fallback(self):
-        assert _safe_int(None, 999) == 999
-
-    def test_nan_returns_fallback(self):
-        assert _safe_int(math.nan, 999) == 999
-
-    def test_inf_returns_fallback(self):
-        assert _safe_int(math.inf, 999) == 999
-
-    def test_neg_inf_returns_fallback(self):
-        assert _safe_int(-math.inf, 999) == 999
-
-    def test_string_numeric(self):
-        assert _safe_int("500", 0) == 500
-
-    def test_string_non_numeric_returns_fallback(self):
-        assert _safe_int("abc", 42) == 42
-
-    def test_zero(self):
-        assert _safe_int(0, 999) == 0
-
-
-# ---------------------------------------------------------------------------
-# _safe_float
-# ---------------------------------------------------------------------------
-
-
-class TestSafeFloat:
-    def test_normal_float(self):
-        assert _safe_float(123.45, 0.0) == pytest.approx(123.45)
-
-    def test_none_returns_fallback(self):
-        assert _safe_float(None, -1.0) == -1.0
-
-    def test_nan_returns_fallback(self):
-        assert _safe_float(math.nan, -1.0) == -1.0
-
-    def test_inf_returns_fallback(self):
-        assert _safe_float(math.inf, -1.0) == -1.0
-
-    def test_neg_inf_returns_fallback(self):
-        assert _safe_float(-math.inf, -1.0) == -1.0
-
-    def test_string_numeric(self):
-        assert _safe_float("3.14", 0.0) == pytest.approx(3.14)
-
-    def test_string_non_numeric_returns_fallback(self):
-        assert _safe_float("bad", 0.0) == 0.0
-
-    def test_zero(self):
-        assert _safe_float(0.0, 99.0) == 0.0
-
-
-# ---------------------------------------------------------------------------
-# _map_spoolman_spool
-# ---------------------------------------------------------------------------
-
-
-MINIMAL_SPOOL = {
-    "id": 1,
-    "filament": {
-        "material": "PLA",
-        "name": "PLA Basic",
-        "color_hex": "FF0000",
-        "weight": 1000.0,
-        "vendor": {"name": "Bambu Lab"},
-    },
-    "used_weight": 250.0,
-    "archived": False,
-    "registered": "2024-01-01T00:00:00Z",
-}
-
-
-class TestMapSpoolmanSpool:
-    def test_basic_mapping(self):
-        result = _map_spoolman_spool(MINIMAL_SPOOL)
-        assert result["id"] == 1
-        assert result["material"] == "PLA"
-        assert result["rgba"] == "FF0000FF"
-        assert result["label_weight"] == 1000
-        assert result["weight_used"] == pytest.approx(250.0)
-        assert result["data_origin"] == "spoolman"
-
-    def test_missing_id_raises(self):
-        spool = {k: v for k, v in MINIMAL_SPOOL.items() if k != "id"}
-        with pytest.raises(ValueError, match="missing required 'id'"):
-            _map_spoolman_spool(spool)
-
-    def test_none_id_raises(self):
-        with pytest.raises(ValueError):
-            _map_spoolman_spool({**MINIMAL_SPOOL, "id": None})
-
-    def test_string_id_raises(self):
-        with pytest.raises(ValueError, match="not a valid integer"):
-            _map_spoolman_spool({**MINIMAL_SPOOL, "id": "abc"})
-
-    def test_zero_id_raises(self):
-        with pytest.raises(ValueError, match="positive integer"):
-            _map_spoolman_spool({**MINIMAL_SPOOL, "id": 0})
-
-    def test_negative_id_raises(self):
-        with pytest.raises(ValueError, match="positive integer"):
-            _map_spoolman_spool({**MINIMAL_SPOOL, "id": -5})
-
-    def test_numeric_string_id_accepted(self):
-        result = _map_spoolman_spool({**MINIMAL_SPOOL, "id": "42"})
-        assert result["id"] == 42
-
-    def test_zero_price_not_converted_to_none(self):
-        spool = {**MINIMAL_SPOOL, "price": 0.0}
-        result = _map_spoolman_spool(spool)
-        assert result["cost_per_kg"] == 0.0
-
-    def test_nonzero_price_preserved(self):
-        spool = {**MINIMAL_SPOOL, "price": 9.99}
-        result = _map_spoolman_spool(spool)
-        assert result["cost_per_kg"] == pytest.approx(9.99)
-
-    def test_none_price_stays_none(self):
-        spool = {**MINIMAL_SPOOL, "price": None}
-        result = _map_spoolman_spool(spool)
-        assert result["cost_per_kg"] is None
-
-    def test_infinity_weight_falls_back(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "weight": math.inf}}
-        result = _map_spoolman_spool(spool)
-        assert result["label_weight"] == 1000
-
-    def test_nan_used_weight_falls_back(self):
-        spool = {**MINIMAL_SPOOL, "used_weight": math.nan}
-        result = _map_spoolman_spool(spool)
-        assert result["weight_used"] == 0.0
-
-    def test_invalid_color_hex_falls_back_to_grey(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ZZZZZZ"}}
-        result = _map_spoolman_spool(spool)
-        assert result["rgba"] == "808080FF"
-
-    def test_short_color_hex_falls_back(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FFF"}}
-        result = _map_spoolman_spool(spool)
-        assert result["rgba"] == "808080FF"
-
-    def test_eight_char_color_hex_falls_back(self):
-        # Only 6-char hex is valid from Spoolman; 8-char (RGBA) should fall back
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FF0000FF"}}
-        result = _map_spoolman_spool(spool)
-        assert result["rgba"] == "808080FF"
-
-    def test_color_hex_with_hash_prefix_stripped(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "#00FF00"}}
-        result = _map_spoolman_spool(spool)
-        assert result["rgba"] == "00FF00FF"
-
-    def test_color_hex_lowercase_normalised(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ff0000"}}
-        result = _map_spoolman_spool(spool)
-        assert result["rgba"] == "FF0000FF"
-
-    def test_none_filament(self):
-        spool = {**MINIMAL_SPOOL, "filament": None}
-        result = _map_spoolman_spool(spool)
-        assert result["material"] == ""
-        assert result["rgba"] == "808080FF"
-        assert result["label_weight"] == 1000
-
-    def test_archived_spool_has_archived_at(self):
-        spool = {**MINIMAL_SPOOL, "archived": True}
-        result = _map_spoolman_spool(spool)
-        assert result["archived_at"] is not None
-
-    def test_subtype_strips_material_prefix(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "material": "PLA", "name": "PLA Basic"}}
-        result = _map_spoolman_spool(spool)
-        assert result["subtype"] == "Basic"
-
-    def test_brand_from_vendor(self):
-        result = _map_spoolman_spool(MINIMAL_SPOOL)
-        assert result["brand"] == "Bambu Lab"
-
-    def test_no_vendor_brand_is_none(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "vendor": None}}
-        result = _map_spoolman_spool(spool)
-        assert result["brand"] is None
-
-    def test_spoolman_location_mapped_to_storage_location(self):
-        spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
-        result = _map_spoolman_spool(spool)
-        assert result["storage_location"] == "Shelf A"
-
-    def test_no_location_gives_none_storage_location(self):
-        result = _map_spoolman_spool(MINIMAL_SPOOL)
-        assert result["storage_location"] is None
-
-    def test_empty_location_gives_none_storage_location(self):
-        spool = {**MINIMAL_SPOOL, "location": ""}
-        result = _map_spoolman_spool(spool)
-        assert result["storage_location"] is None
-
-    def test_spoolman_location_key_not_in_result(self):
-        spool = {**MINIMAL_SPOOL, "location": "Shelf A"}
-        result = _map_spoolman_spool(spool)
-        assert "spoolman_location" not in result
-
-    def test_core_weight_from_filament_spool_weight(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
-        result = _map_spoolman_spool(spool)
-        assert result["core_weight"] == 196
-
-    def test_core_weight_fallback_when_spool_weight_missing(self):
-        result = _map_spoolman_spool(MINIMAL_SPOOL)
-        assert result["core_weight"] == 250
-
-    def test_core_weight_fallback_when_spool_weight_none(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
-        result = _map_spoolman_spool(spool)
-        assert result["core_weight"] == 250
-
-    def test_core_weight_float_truncated_to_int(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 180.9}}
-        result = _map_spoolman_spool(spool)
-        assert result["core_weight"] == 180
-
-    def test_spool_level_spool_weight_takes_priority_over_filament(self):
-        spool = {**MINIMAL_SPOOL, "spool_weight": 300, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
-        assert _map_spoolman_spool(spool)["core_weight"] == 300
-
-    def test_spool_level_zero_spool_weight_not_treated_as_missing(self):
-        spool = {**MINIMAL_SPOOL, "spool_weight": 0, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
-        assert _map_spoolman_spool(spool)["core_weight"] == 0
-
-    def test_spool_level_none_falls_back_to_filament(self):
-        spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
-        assert _map_spoolman_spool(spool)["core_weight"] == 196
-
-    def test_spool_level_absent_falls_back_to_filament(self):
-        spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}}
-        assert _map_spoolman_spool(spool)["core_weight"] == 196
-
-    def test_both_levels_none_uses_fallback(self):
-        spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
-        assert _map_spoolman_spool(spool)["core_weight"] == 250
-
-
-# ---------------------------------------------------------------------------
-# F4: _safe_optional_float unit tests
-# ---------------------------------------------------------------------------
-
-
-class TestSafeOptionalFloat:
-    """F4: Direct unit tests for _safe_optional_float (NaN/Inf safety)."""
-
-    def test_normal_value(self):
-        import pytest
-
-        from backend.app.api.routes._spoolman_helpers import _safe_optional_float
-
-        assert _safe_optional_float(9.99) == pytest.approx(9.99)
-
-    def test_none_returns_none(self):
-        from backend.app.api.routes._spoolman_helpers import _safe_optional_float
-
-        assert _safe_optional_float(None) is None
-
-    def test_nan_returns_none(self):
-        import math
-
-        from backend.app.api.routes._spoolman_helpers import _safe_optional_float
-
-        assert _safe_optional_float(math.nan) is None
-
-    def test_inf_returns_none(self):
-        import math
-
-        from backend.app.api.routes._spoolman_helpers import _safe_optional_float
-
-        assert _safe_optional_float(math.inf) is None
-
-    def test_neg_inf_returns_none(self):
-        import math
-
-        from backend.app.api.routes._spoolman_helpers import _safe_optional_float
-
-        assert _safe_optional_float(-math.inf) is None
-
-    def test_zero_returns_zero(self):
-        from backend.app.api.routes._spoolman_helpers import _safe_optional_float
-
-        assert _safe_optional_float(0.0) == 0.0
-
-    def test_string_numeric(self):
-        import pytest
-
-        from backend.app.api.routes._spoolman_helpers import _safe_optional_float
-
-        assert _safe_optional_float("3.14") == pytest.approx(3.14)
-
-    def test_string_non_numeric_returns_none(self):
-        from backend.app.api.routes._spoolman_helpers import _safe_optional_float
-
-        assert _safe_optional_float("bad") is None
-
-
-class TestMapSpoolmanSpoolSlicerFilament:
-    """slicer_filament round-trip via Spoolman extra dict.
-
-    Spoolman has no native slicer_filament field, so we persist BambuStudio
-    presets under bambu_slicer_filament[_name] keys in the spool's extra
-    dict (JSON-encoded strings, like every Spoolman extra value). The map
-    function unwraps those values and exposes them as slicer_filament /
-    slicer_filament_name on the InventorySpool shape. Without this round-trip
-    the user's selected slicer preset is silently dropped on save (#1114).
-    """
-
-    def test_slicer_filament_unwrapped_from_extra(self):
-        spool = {
-            **MINIMAL_SPOOL,
-            "extra": {
-                "bambu_slicer_filament": '"PFUSf543b298f8ea66"',
-                "bambu_slicer_filament_name": '"Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"',
-            },
-        }
-        result = _map_spoolman_spool(spool)
-        assert result["slicer_filament"] == "PFUSf543b298f8ea66"
-        assert result["slicer_filament_name"] == "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"
-
-    def test_slicer_filament_falls_back_to_filament_name(self):
-        # Spool has no bambu_slicer_filament_name override → use Spoolman's filament.name
-        spool = {**MINIMAL_SPOOL, "extra": {}}
-        result = _map_spoolman_spool(spool)
-        assert result["slicer_filament"] is None
-        assert result["slicer_filament_name"] == "PLA Basic"  # from filament.name
-
-    def test_empty_string_extra_treated_as_unset(self):
-        # JSON-encoded empty string is how the user clears the field
-        spool = {
-            **MINIMAL_SPOOL,
-            "extra": {
-                "bambu_slicer_filament": '""',
-                "bambu_slicer_filament_name": '""',
-            },
-        }
-        result = _map_spoolman_spool(spool)
-        assert result["slicer_filament"] is None
-        # Falls back to filament.name when the override is cleared
-        assert result["slicer_filament_name"] == "PLA Basic"
-
-    def test_non_json_extra_value_passed_through(self):
-        # Tolerate bare-string values written without JSON encoding
-        # (older data, manual writes via Spoolman UI, etc.)
-        spool = {
-            **MINIMAL_SPOOL,
-            "extra": {"bambu_slicer_filament": "GFL05"},
-        }
-        result = _map_spoolman_spool(spool)
-        assert result["slicer_filament"] == "GFL05"
-
-
-class TestExtractExtraStr:
-    """JSON-encoded extra-string unwrapper used by _map_spoolman_spool."""
-
-    def test_unwraps_quoted_string(self):
-        from backend.app.api.routes._spoolman_helpers import _extract_extra_str
-
-        assert _extract_extra_str({"k": '"hello"'}, "k") == "hello"
-
-    def test_returns_empty_for_missing_key(self):
-        from backend.app.api.routes._spoolman_helpers import _extract_extra_str
-
-        assert _extract_extra_str({}, "k") == ""
-
-    def test_returns_empty_for_non_string_value(self):
-        from backend.app.api.routes._spoolman_helpers import _extract_extra_str
-
-        # Spoolman extra values are stringified; numeric values shouldn't sneak in
-        # but if they do we treat them as unset rather than crashing
-        assert _extract_extra_str({"k": 42}, "k") == ""
-
-    def test_returns_empty_for_json_null(self):
-        from backend.app.api.routes._spoolman_helpers import _extract_extra_str
-
-        # null isn't a string after decode → treat as unset
-        assert _extract_extra_str({"k": "null"}, "k") == ""
-
-    def test_passes_through_bare_string_on_decode_error(self):
-        from backend.app.api.routes._spoolman_helpers import _extract_extra_str
-
-        # Tolerate non-JSON-encoded values
-        assert _extract_extra_str({"k": "GFL05"}, "k") == "GFL05"
-
-
-class TestMapSpoolmanSpoolPrice:
-    """F4: NaN/Inf price in _map_spoolman_spool gives None cost_per_kg."""
-
-    def test_nan_price_gives_none_cost_per_kg(self):
-        import math
-
-        from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
-
-        spool = {**MINIMAL_SPOOL, "price": math.nan}
-        assert _map_spoolman_spool(spool)["cost_per_kg"] is None
-
-    def test_inf_price_gives_none_cost_per_kg(self):
-        import math
-
-        from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
-
-        spool = {**MINIMAL_SPOOL, "price": math.inf}
-        assert _map_spoolman_spool(spool)["cost_per_kg"] is None

+ 0 - 424
backend/tests/unit/test_spoolman_inventory_methods.py

@@ -1,424 +0,0 @@
-"""Unit tests for new SpoolmanClient inventory methods.
-
-Covers: get_spool, get_all_spools, delete_spool, set_spool_archived,
-update_spool_full, find_or_create_vendor, find_or_create_filament.
-"""
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import httpx
-import pytest
-
-from backend.app.services.spoolman import SpoolmanClient, SpoolmanUnavailableError
-
-
-@pytest.fixture
-def client():
-    return SpoolmanClient("http://localhost:7912")
-
-
-def _make_response(json_data, status_code=200):
-    """Build a mock httpx response."""
-    resp = MagicMock()
-    resp.status_code = status_code
-    resp.json.return_value = json_data
-    resp.raise_for_status = MagicMock()
-    return resp
-
-
-SAMPLE_SPOOL = {
-    "id": 42,
-    "remaining_weight": 750.0,
-    "used_weight": 250.0,
-    "archived": False,
-    "filament": {"id": 7, "name": "PLA Basic", "material": "PLA"},
-}
-
-SAMPLE_FILAMENT = {
-    "id": 7,
-    "name": "PLA Basic",
-    "material": "PLA",
-    "color_hex": "FF0000",
-    "weight": 1000.0,
-    "vendor": {"id": 3, "name": "Bambu Lab"},
-}
-
-SAMPLE_VENDOR = {"id": 3, "name": "Bambu Lab"}
-
-
-# ---------------------------------------------------------------------------
-# get_spool
-# ---------------------------------------------------------------------------
-
-
-class TestGetSpool:
-    @pytest.mark.asyncio
-    async def test_returns_spool_dict_on_success(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            result = await client.get_spool(42)
-        assert result == SAMPLE_SPOOL
-        mock_http.request.assert_called_once_with("GET", "http://localhost:7912/api/v1/spool/42", json=None)
-
-    @pytest.mark.asyncio
-    async def test_raises_unavailable_on_http_error(self, client):
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(side_effect=Exception("not found"))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.get_spool(99)
-
-    @pytest.mark.asyncio
-    async def test_raises_not_found_on_404_response(self, client):
-        """get_spool raises SpoolmanNotFoundError when Spoolman returns HTTP 404 (PT-I3)."""
-        from backend.app.services.spoolman import SpoolmanNotFoundError
-
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(None, status_code=404))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanNotFoundError),
-        ):
-            await client.get_spool(99)
-
-    @pytest.mark.asyncio
-    async def test_raises_client_error_on_4xx_response(self, client):
-        """get_spool raises SpoolmanClientError (not SpoolmanUnavailableError) on non-404 4xx (H2)."""
-        from backend.app.services.spoolman import SpoolmanClientError
-
-        mock_request = MagicMock()
-        mock_request.url = "http://localhost:7912/api/v1/spool/42"
-        mock_resp_obj = MagicMock()
-        mock_resp_obj.status_code = 422
-
-        mock_http = AsyncMock()
-        resp = _make_response(None, status_code=422)
-        resp.raise_for_status = MagicMock(
-            side_effect=httpx.HTTPStatusError("Unprocessable", request=mock_request, response=mock_resp_obj)
-        )
-        mock_http.request = AsyncMock(return_value=resp)
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanClientError) as exc_info,
-        ):
-            await client.get_spool(42)
-        assert exc_info.value.status_code == 422
-
-
-# ---------------------------------------------------------------------------
-# get_all_spools
-# ---------------------------------------------------------------------------
-
-
-class TestGetAllSpools:
-    @pytest.mark.asyncio
-    async def test_returns_list_without_archived_by_default(self, client):
-        mock_http = AsyncMock()
-        mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL]))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            result = await client.get_all_spools()
-        assert result == [SAMPLE_SPOOL]
-        mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params=None)
-
-    @pytest.mark.asyncio
-    async def test_passes_allow_archived_param(self, client):
-        mock_http = AsyncMock()
-        mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL]))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            await client.get_all_spools(allow_archived=True)
-        mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params={"allow_archived": "true"})
-
-    @pytest.mark.asyncio
-    async def test_raises_unavailable_on_error(self, client):
-        mock_http = AsyncMock()
-        mock_http.get = AsyncMock(side_effect=Exception("connection error"))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.get_all_spools()
-
-
-# ---------------------------------------------------------------------------
-# delete_spool
-# ---------------------------------------------------------------------------
-
-
-class TestDeleteSpool:
-    @pytest.mark.asyncio
-    async def test_returns_none_on_success(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(None))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            result = await client.delete_spool(42)
-        assert result is None
-        mock_http.request.assert_called_once_with("DELETE", "http://localhost:7912/api/v1/spool/42", json=None)
-
-    @pytest.mark.asyncio
-    async def test_raises_unavailable_on_error(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(side_effect=Exception("server error"))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.delete_spool(42)
-
-
-# ---------------------------------------------------------------------------
-# set_spool_archived
-# ---------------------------------------------------------------------------
-
-
-class TestSetSpoolArchived:
-    @pytest.mark.asyncio
-    async def test_archives_spool(self, client):
-        archived_spool = {**SAMPLE_SPOOL, "archived": True}
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(archived_spool))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            result = await client.set_spool_archived(42, archived=True)
-        assert result == archived_spool
-        mock_http.request.assert_called_once_with(
-            "PATCH",
-            "http://localhost:7912/api/v1/spool/42",
-            json={"archived": True},
-        )
-
-    @pytest.mark.asyncio
-    async def test_restores_spool(self, client):
-        restored_spool = {**SAMPLE_SPOOL, "archived": False}
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(restored_spool))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            result = await client.set_spool_archived(42, archived=False)
-        assert result == restored_spool
-        mock_http.request.assert_called_once_with(
-            "PATCH",
-            "http://localhost:7912/api/v1/spool/42",
-            json={"archived": False},
-        )
-
-    @pytest.mark.asyncio
-    async def test_raises_unavailable_on_error(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(side_effect=Exception("timeout"))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.set_spool_archived(42, archived=True)
-
-
-# ---------------------------------------------------------------------------
-# update_spool_full
-# ---------------------------------------------------------------------------
-
-
-class TestUpdateSpoolFull:
-    @pytest.mark.asyncio
-    async def test_sends_only_provided_fields(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            await client.update_spool_full(42, remaining_weight=600.0, comment="note")
-        call_json = mock_http.request.call_args.kwargs["json"]
-        assert call_json == {"remaining_weight": 600.0, "comment": "note"}
-
-    @pytest.mark.asyncio
-    async def test_clear_location_sets_none(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            await client.update_spool_full(42, clear_location=True)
-        call_json = mock_http.request.call_args.kwargs["json"]
-        assert call_json == {"location": None}
-
-    @pytest.mark.asyncio
-    async def test_location_set_when_not_clearing(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            await client.update_spool_full(42, location="Shelf A")
-        call_json = mock_http.request.call_args.kwargs["json"]
-        assert call_json == {"location": "Shelf A"}
-
-    @pytest.mark.asyncio
-    async def test_empty_comment_sent_as_none(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL))
-        with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)):
-            await client.update_spool_full(42, comment="")
-        call_json = mock_http.request.call_args.kwargs["json"]
-        assert call_json == {"comment": None}
-
-    @pytest.mark.asyncio
-    async def test_raises_unavailable_on_error(self, client):
-        mock_http = AsyncMock()
-        mock_http.request = AsyncMock(side_effect=Exception("network"))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.update_spool_full(42, remaining_weight=500.0)
-
-
-# ---------------------------------------------------------------------------
-# find_or_create_vendor
-# ---------------------------------------------------------------------------
-
-
-class TestFindOrCreateVendor:
-    @pytest.mark.asyncio
-    async def test_returns_existing_vendor_id(self, client):
-        with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])):
-            result = await client.find_or_create_vendor("Bambu Lab")
-        assert result == 3
-
-    @pytest.mark.asyncio
-    async def test_case_insensitive_match(self, client):
-        with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])):
-            result = await client.find_or_create_vendor("bambu lab")
-        assert result == 3
-
-    @pytest.mark.asyncio
-    async def test_creates_vendor_when_not_found(self, client):
-        new_vendor = {"id": 10, "name": "New Brand"}
-        with (
-            patch.object(client, "get_vendors", AsyncMock(return_value=[])),
-            patch.object(client, "create_vendor", AsyncMock(return_value=new_vendor)) as mock_create,
-        ):
-            result = await client.find_or_create_vendor("New Brand")
-        assert result == 10
-        mock_create.assert_called_once_with("New Brand")
-
-    @pytest.mark.asyncio
-    async def test_raises_when_create_fails(self, client):
-        with (
-            patch.object(client, "get_vendors", AsyncMock(return_value=[])),
-            patch.object(client, "create_vendor", AsyncMock(side_effect=SpoolmanUnavailableError("unreachable"))),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.find_or_create_vendor("Ghost Brand")
-
-
-# ---------------------------------------------------------------------------
-# find_or_create_filament
-# ---------------------------------------------------------------------------
-
-
-class TestFindOrCreateFilament:
-    @pytest.mark.asyncio
-    async def test_returns_existing_filament_id(self, client):
-        with (
-            patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
-            patch.object(client, "get_filaments", AsyncMock(return_value=[SAMPLE_FILAMENT])),
-        ):
-            result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000)
-        assert result == 7
-
-    @pytest.mark.asyncio
-    async def test_creates_filament_when_no_match(self, client):
-        new_filament = {"id": 99, "name": "PETG Pro"}
-        with (
-            patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
-            patch.object(client, "get_filaments", AsyncMock(return_value=[])),
-            patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
-        ):
-            result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000)
-        assert result == 99
-        mock_create.assert_called_once_with(
-            name="PETG Pro",
-            vendor_id=3,
-            material="PETG",
-            color_hex="00FF00",
-            color_name=None,
-            weight=1000.0,
-        )
-
-    @pytest.mark.asyncio
-    async def test_no_brand_skips_vendor_lookup(self, client):
-        filament_no_vendor = {
-            **SAMPLE_FILAMENT,
-            "vendor": None,
-            "name": "PLA Basic",
-            "color_hex": "FF0000",
-        }
-        with (
-            patch.object(client, "get_filaments", AsyncMock(return_value=[filament_no_vendor])),
-        ):
-            result = await client.find_or_create_filament("PLA", "Basic", None, "FF0000", 1000)
-        assert result == 7
-
-    @pytest.mark.asyncio
-    async def test_color_hex_normalised_to_uppercase(self, client):
-        with (
-            patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)),
-            patch.object(client, "get_filaments", AsyncMock(return_value=[])),
-            patch.object(client, "create_filament", AsyncMock(return_value={"id": 5})) as mock_create,
-        ):
-            await client.find_or_create_filament("ABS", "", None, "ff0000", 750)
-        mock_create.assert_called_once_with(
-            name="ABS",
-            vendor_id=None,
-            material="ABS",
-            color_hex="FF0000",
-            color_name=None,
-            weight=750.0,
-        )
-
-    @pytest.mark.asyncio
-    async def test_raises_when_create_fails(self, client):
-        with (
-            patch.object(client, "find_or_create_vendor", AsyncMock(return_value=1)),
-            patch.object(client, "get_filaments", AsyncMock(return_value=[])),
-            patch.object(client, "create_filament", AsyncMock(side_effect=SpoolmanUnavailableError("unreachable"))),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500)
-
-
-# ---------------------------------------------------------------------------
-# get_filaments / get_vendors / get_external_filaments error propagation (H11)
-# ---------------------------------------------------------------------------
-
-
-class TestGetFilamentsRaisesOnError:
-    @pytest.mark.asyncio
-    async def test_raises_unavailable_on_error(self, client):
-        mock_http = AsyncMock()
-        mock_http.get = AsyncMock(side_effect=Exception("timeout"))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.get_filaments()
-
-
-class TestGetVendorsRaisesOnError:
-    @pytest.mark.asyncio
-    async def test_raises_unavailable_on_error(self, client):
-        mock_http = AsyncMock()
-        mock_http.get = AsyncMock(side_effect=Exception("timeout"))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.get_vendors()
-
-
-class TestGetExternalFilamentsRaisesOnError:
-    @pytest.mark.asyncio
-    async def test_raises_unavailable_on_error(self, client):
-        mock_http = AsyncMock()
-        mock_http.get = AsyncMock(side_effect=Exception("timeout"))
-        with (
-            patch.object(client, "_get_client", AsyncMock(return_value=mock_http)),
-            pytest.raises(SpoolmanUnavailableError),
-        ):
-            await client.get_external_filaments()

+ 0 - 77
backend/tests/unit/test_spoolman_slot_ddl.py

@@ -1,77 +0,0 @@
-"""T-Gap 4: PostgreSQL DDL dialect tests for spoolman_slot_assignments table."""
-
-import re
-
-import pytest
-
-
-def _extract_spoolman_slot_ddl(is_sqlite: bool) -> str:
-    """Extract the spoolman_slot_assignments DDL from database.py."""
-    import inspect
-    from unittest.mock import patch
-
-    import backend.app.core.database as db_module
-
-    source = inspect.getsource(db_module)
-    # Find the block that creates spoolman_slot_assignments
-    start = source.find("CREATE TABLE IF NOT EXISTS spoolman_slot_assignments")
-    assert start != -1, "spoolman_slot_assignments DDL not found"
-    # Scan forward to find the matching end of the SQL string
-    block = source[start : start + 2000]
-    return block
-
-
-class TestSpoolmanSlotDdl:
-    """Verify the DDL for spoolman_slot_assignments contains required constraints."""
-
-    def test_sqlite_ddl_has_named_unique_constraint(self):
-        ddl = _extract_spoolman_slot_ddl(is_sqlite=True)
-        assert "uq_slot_assignment" in ddl, "Named UNIQUE constraint missing from SQLite DDL"
-
-    def test_sqlite_ddl_has_ams_id_check(self):
-        ddl = _extract_spoolman_slot_ddl(is_sqlite=True)
-        # ams_id allows 0–7 for physical AMS units and 255 for external slot
-        assert re.search(r"ams_id.*CHECK.*ams_id.*>=.*0.*AND.*ams_id.*<=.*7.*OR.*ams_id.*=.*255", ddl, re.DOTALL), (
-            "ams_id CHECK constraint missing from SQLite DDL"
-        )
-
-    def test_sqlite_ddl_has_tray_id_check(self):
-        ddl = _extract_spoolman_slot_ddl(is_sqlite=True)
-        assert re.search(r"tray_id.*CHECK.*tray_id.*>=.*0.*AND.*tray_id.*<=.*3", ddl, re.DOTALL), (
-            "tray_id CHECK constraint missing from SQLite DDL"
-        )
-
-    def test_postgres_ddl_has_named_unique_constraint(self):
-        ddl = _extract_spoolman_slot_ddl(is_sqlite=False)
-        # PostgreSQL DDL appears after the SQLite block
-        pg_start = ddl.find("SERIAL PRIMARY KEY")
-        assert pg_start != -1 or "uq_slot_assignment" in ddl, "uq_slot_assignment not found in DDL"
-
-    def test_orm_model_has_named_unique_constraint(self):
-        from sqlalchemy import inspect as sa_inspect
-
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        table = SpoolmanSlotAssignment.__table__
-        constraint_names = {c.name for c in table.constraints}
-        assert "uq_slot_assignment" in constraint_names, (
-            f"uq_slot_assignment not in ORM constraints: {constraint_names}"
-        )
-
-    def test_orm_model_has_ams_id_check_constraint(self):
-        from sqlalchemy import CheckConstraint
-
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        table = SpoolmanSlotAssignment.__table__
-        check_names = {c.name for c in table.constraints if isinstance(c, CheckConstraint)}
-        assert "ck_ams_id_range" in check_names, f"ck_ams_id_range not in ORM check constraints: {check_names}"
-
-    def test_orm_model_has_tray_id_check_constraint(self):
-        from sqlalchemy import CheckConstraint
-
-        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-        table = SpoolmanSlotAssignment.__table__
-        check_names = {c.name for c in table.constraints if isinstance(c, CheckConstraint)}
-        assert "ck_tray_id_range" in check_names, f"ck_tray_id_range not in ORM check constraints: {check_names}"

+ 0 - 223
backend/tests/unit/test_spoolman_tracking.py

@@ -1,223 +0,0 @@
-"""Unit tests for Spoolman tracking service helpers."""
-
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-from backend.app.services.spoolman_tracking import (
-    _get_fallback_spool_tag,
-    _global_tray_id_to_ams_slot,
-    _hash_serial_to_hex32,
-    _resolve_global_tray_id,
-    _resolve_spool_tag,
-    build_ams_tray_lookup,
-    store_print_data,
-)
-
-
-class TestResolveSpoolTag:
-    """Tests for _resolve_spool_tag()."""
-
-    def test_prefers_tray_uuid_over_tag_uid(self):
-        tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": "DEADBEEF"}
-        assert _resolve_spool_tag(tray) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
-
-    def test_falls_back_to_tag_uid_when_no_uuid(self):
-        tray = {"tray_uuid": "", "tag_uid": "DEADBEEF"}
-        assert _resolve_spool_tag(tray) == "DEADBEEF"
-
-    def test_falls_back_to_tag_uid_when_uuid_zero(self):
-        tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"}
-        assert _resolve_spool_tag(tray) == "DEADBEEF"
-
-    def test_rejects_zero_tag_uid(self):
-        tray = {"tray_uuid": "", "tag_uid": "0000000000000000"}
-        assert _resolve_spool_tag(tray) == ""
-
-    def test_uses_fallback_tag_when_ids_missing(self):
-        tray = {"tray_uuid": "", "tag_uid": ""}
-        # global_tray_id 0 -> ams_id 0, tray_id 0
-        assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "ABA7845700000000"
-
-    def test_uses_fallback_tag_when_ids_zero(self):
-        tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "0000000000000000"}
-        # global_tray_id 5 -> ams_id 1, tray_id 1
-        assert _resolve_spool_tag(tray, "01P00A000000000", 5) == "ABA7845700010001"
-
-    def test_prefers_tray_uuid_over_fallback_when_non_zero(self):
-        tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": ""}
-        assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
-
-    def test_empty_both(self):
-        tray = {"tray_uuid": "", "tag_uid": ""}
-        assert _resolve_spool_tag(tray) == ""
-
-    def test_missing_keys(self):
-        assert _resolve_spool_tag({}) == ""
-
-    def test_zero_uuid_no_tag(self):
-        tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": ""}
-        assert _resolve_spool_tag(tray) == ""
-
-
-class TestResolveGlobalTrayId:
-    """Tests for _resolve_global_tray_id()."""
-
-    def test_default_mapping(self):
-        """slot 1 -> tray 0, slot 2 -> tray 1, etc."""
-        assert _resolve_global_tray_id(1, None) == 0
-        assert _resolve_global_tray_id(2, None) == 1
-        assert _resolve_global_tray_id(4, None) == 3
-
-    def test_custom_mapping(self):
-        """Custom slot_to_tray overrides default."""
-        mapping = [5, 2, -1, 0]
-        assert _resolve_global_tray_id(1, mapping) == 5
-        assert _resolve_global_tray_id(2, mapping) == 2
-        assert _resolve_global_tray_id(4, mapping) == 0
-
-    def test_unmapped_slot(self):
-        """Slot with -1 in mapping uses default."""
-        mapping = [5, -1, 2, 0]
-        assert _resolve_global_tray_id(2, mapping) == 1  # default: slot 2 -> tray 1
-
-    def test_slot_beyond_mapping(self):
-        """Slot beyond mapping length uses default."""
-        mapping = [5, 2]
-        assert _resolve_global_tray_id(3, mapping) == 2  # default: slot 3 -> tray 2
-
-    def test_empty_mapping(self):
-        mapping = []
-        assert _resolve_global_tray_id(1, mapping) == 0
-
-
-class TestFallbackTagHelpers:
-    """Tests for frontend-mirrored fallback tag helpers."""
-
-    def test_hash_serial_matches_frontend_algorithm(self):
-        assert _hash_serial_to_hex32("01P00A000000000") == "ABA78457"
-        # Frontend trims and uppercases before hashing
-        assert _hash_serial_to_hex32(" 01p00a000000000 ") == "ABA78457"
-
-    def test_global_tray_to_ams_slot_standard_ams(self):
-        assert _global_tray_id_to_ams_slot(0) == (0, 0)
-        assert _global_tray_id_to_ams_slot(7) == (1, 3)
-
-    def test_global_tray_to_ams_slot_ams_ht(self):
-        assert _global_tray_id_to_ams_slot(128) == (128, 0)
-        assert _global_tray_id_to_ams_slot(135) == (135, 0)
-
-    def test_global_tray_to_ams_slot_external(self):
-        assert _global_tray_id_to_ams_slot(254) == (255, 0)
-        assert _global_tray_id_to_ams_slot(255) == (255, 1)
-
-    def test_get_fallback_spool_tag_standard(self):
-        assert _get_fallback_spool_tag("01P00A000000000", 5) == "ABA7845700010001"
-
-    def test_get_fallback_spool_tag_ams_ht(self):
-        assert _get_fallback_spool_tag("01P00A000000000", 128) == "ABA7845700800000"
-
-    def test_get_fallback_spool_tag_external(self):
-        assert _get_fallback_spool_tag("01P00A000000000", 255) == "ABA7845700FF0001"
-
-
-class TestBuildAmsTrayLookup:
-    """Tests for build_ams_tray_lookup()."""
-
-    def test_single_ams_unit(self):
-        raw = {
-            "ams": [
-                {
-                    "id": 0,
-                    "tray": [
-                        {"id": 0, "tray_uuid": "AAA", "tag_uid": "111", "tray_type": "PLA"},
-                        {"id": 1, "tray_uuid": "BBB", "tag_uid": "222", "tray_type": "ABS"},
-                    ],
-                }
-            ]
-        }
-        lookup = build_ams_tray_lookup(raw)
-        assert lookup[0] == {"tray_uuid": "AAA", "tag_uid": "111", "tray_type": "PLA"}
-        assert lookup[1] == {"tray_uuid": "BBB", "tag_uid": "222", "tray_type": "ABS"}
-
-    def test_multiple_ams_units(self):
-        raw = {
-            "ams": [
-                {"id": 0, "tray": [{"id": 0, "tray_uuid": "A", "tag_uid": "", "tray_type": "PLA"}]},
-                {"id": 1, "tray": [{"id": 0, "tray_uuid": "B", "tag_uid": "", "tray_type": "PETG"}]},
-            ]
-        }
-        lookup = build_ams_tray_lookup(raw)
-        assert 0 in lookup  # AMS 0, tray 0
-        assert 4 in lookup  # AMS 1, tray 0 (1*4+0)
-        assert lookup[4]["tray_uuid"] == "B"
-
-    def test_external_spool(self):
-        raw = {
-            "ams": [],
-            "vt_tray": [{"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"}],
-        }
-        lookup = build_ams_tray_lookup(raw)
-        assert 254 in lookup
-        assert lookup[254]["tray_type"] == "TPU"
-
-    def test_empty_external_spool_skipped(self):
-        raw = {"ams": [], "vt_tray": [{"tray_type": ""}]}
-        lookup = build_ams_tray_lookup(raw)
-        assert 254 not in lookup
-
-    def test_no_ams_data(self):
-        assert build_ams_tray_lookup({}) == {}
-        assert build_ams_tray_lookup({"ams": []}) == {}
-
-    def test_missing_fields_default(self):
-        raw = {"ams": [{"id": 0, "tray": [{"id": 0}]}]}
-        lookup = build_ams_tray_lookup(raw)
-        assert lookup[0] == {"tray_uuid": "", "tag_uid": "", "tray_type": ""}
-
-
-class TestStorePrintData:
-    """Tests for store_print_data()."""
-
-    @pytest.mark.asyncio
-    async def test_prefers_explicit_ams_mapping_over_queue_mapping(self):
-        db = AsyncMock()
-        delete_result = MagicMock()
-        db.execute = AsyncMock(side_effect=[delete_result])
-        db.add = MagicMock()
-        db.commit = AsyncMock()
-
-        printer_manager = MagicMock()
-        printer_manager.get_status.return_value = SimpleNamespace(
-            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}, {"id": 1, "tray_type": "PLA"}]}]}
-        )
-
-        mock_settings = MagicMock()
-        mock_path = MagicMock()
-        mock_path.exists.return_value = True
-        mock_settings.base_dir.__truediv__.return_value = mock_path
-
-        with (
-            patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
-            patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=["true", "true"])),
-            patch(
-                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
-                return_value=[{"slot_id": 1, "used_g": 3.83, "type": "PLA", "color": "#FF0000"}],
-            ),
-            patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
-            patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
-        ):
-            await store_print_data(
-                printer_id=1,
-                archive_id=15,
-                file_path="archives/test.3mf",
-                db=db,
-                printer_manager=printer_manager,
-                ams_mapping=[1, -1, -1, -1],
-            )
-
-        db.add.assert_called_once()
-        tracking = db.add.call_args.args[0]
-        assert tracking.slot_to_tray == [1, -1, -1, -1]
-        db.execute.assert_called_once()

+ 0 - 96
backend/tests/unit/test_ssrf_guard.py

@@ -1,96 +0,0 @@
-"""T-Gap 5: Unit tests for assert_safe_spoolman_url."""
-
-import pytest
-
-from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
-
-
-class TestSsrfGuardAccepted:
-    """URLs that must be accepted (normal Spoolman topologies)."""
-
-    def test_localhost_http(self):
-        assert_safe_spoolman_url("http://localhost:7912")
-
-    def test_localhost_https(self):
-        assert_safe_spoolman_url("https://localhost:7912")
-
-    def test_loopback_ipv4(self):
-        assert_safe_spoolman_url("http://127.0.0.1:7912")
-
-    def test_rfc1918_192_168(self):
-        assert_safe_spoolman_url("http://192.168.1.50:7912")
-
-    def test_rfc1918_10_x(self):
-        assert_safe_spoolman_url("http://10.0.0.5:7912")
-
-    def test_rfc1918_172_16(self):
-        assert_safe_spoolman_url("http://172.16.0.1:7912")
-
-    def test_hostname_based(self):
-        assert_safe_spoolman_url("http://spoolman.local:7912")
-
-    def test_internal_dns(self):
-        assert_safe_spoolman_url("http://internal.corp:7912")
-
-    def test_https_with_path(self):
-        assert_safe_spoolman_url("https://spoolman.example.com/api")
-
-
-class TestSsrfGuardRejected:
-    """URLs that must be rejected as SSRF-dangerous."""
-
-    def test_cloud_metadata_169_254(self):
-        with pytest.raises(ValueError, match="Spoolman URL"):
-            assert_safe_spoolman_url("http://169.254.169.254/latest/meta-data/")
-
-    def test_alibaba_cloud_metadata(self):
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://100.100.100.200/latest/meta-data/")
-
-    def test_multicast_ipv4(self):
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://224.0.0.1:7912")
-
-    def test_unspecified_ipv4(self):
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://0.0.0.0:7912")
-
-    def test_scheme_file(self):
-        with pytest.raises(ValueError, match="http or https"):
-            assert_safe_spoolman_url("file:///etc/passwd")
-
-    def test_scheme_gopher(self):
-        with pytest.raises(ValueError, match="http or https"):
-            assert_safe_spoolman_url("gopher://spoolman.local:7912")
-
-    def test_scheme_dict(self):
-        with pytest.raises(ValueError, match="http or https"):
-            assert_safe_spoolman_url("dict://localhost:1234/")
-
-    def test_numeric_encoded_decimal(self):
-        # 2130706433 == 127.0.0.1 in decimal — must be rejected
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://2130706433:7912")
-
-    def test_numeric_encoded_hex(self):
-        # 0x7f000001 == 127.0.0.1 in hex — must be rejected
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://0x7f000001:7912")
-
-    def test_ipv4_mapped_ipv6_metadata(self):
-        # ::ffff:169.254.169.254 — IPv4-mapped IPv6 bypass attempt
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://[::ffff:169.254.169.254]:7912")
-
-    def test_multicast_ipv6(self):
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://[ff02::1]:7912")
-
-    def test_unspecified_ipv6(self):
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://[::]:7912")
-
-    def test_aws_imds_ipv6_blocked(self):
-        """F10: AWS IMDS IPv6 address fd00:ec2::254 must be blocked."""
-        with pytest.raises(ValueError):
-            assert_safe_spoolman_url("http://[fd00:ec2::254]:7912")

+ 0 - 383
docs/spoolman-inventory-test-plan.md

@@ -1,383 +0,0 @@
-# Spoolman Inventory UI — Test Plan
-
-**Build under test:** either branch `feature/spoolman-inventory-ui` OR docker image `bambuddy:spoolman-test_20260505` (both contain the merge with dev as of 2026-05-05)
-**Issued:** 2026-05-05
-
----
-
-## How to use this plan
-
-**Run ALL tests in Local (internal) mode first. Only after the entire Local pass is complete do you switch to Spoolman mode and run everything again. Do not interleave the two modes.**
-
-1. **Pass 1 — Local (internal DB) mode.** Confirm Spoolman is **disabled** in Settings, then walk top-to-bottom through every section (0 → A → B → C → D → E), filling the **"Pass 1 (Local)"** column on every row. Rows tagged Spoolman-only get `N/A` for this pass. Do not touch Section F yet.
-2. **Pass 2 — Spoolman mode.** Only when Pass 1 is fully done: enable Spoolman in Settings, paste the Spoolman URL, save, **fully reload the browser**. Then walk the same sections 0 → A → B → C → D → E top-to-bottom again, filling the **"Pass 2 (Spoolman)"** column. Rows tagged Local-only get `N/A` for this pass.
-3. **Section F — Final state diff.** Run only once, after Pass 2 completes.
-
-A few ground rules for both passes:
-- **The "Verify (slicer)" steps are MANDATORY** — see [How to verify in the slicer](#how-to-verify-in-the-slicer) below for the exact protocol. Most testers skip these. Don't. The slicer is the only place that proves AMS slot config actually applied — Bambuddy's UI can show a green checkmark while the printer's calibration table is unchanged.
-- **Mark each row P (pass) / F (fail) / B (blocked)**. For F/B, paste a one-line note + screenshot link.
-- **Stop and file a bug** the moment you hit an F. Don't keep going on a broken path — downstream results become noise.
-- **Do not flip Spoolman on/off mid-pass.** If you accidentally do, restart the current pass.
-
----
-
-## How to verify in the slicer
-
-Whenever a row says **Verify (slicer)**, do this — it is the only way to catch silent failures where Bambuddy's UI says "applied" but the printer's calibration table never changed.
-
-### Pick OrcaSlicer over BambuStudio
-
-> **BambuStudio has a known bug**: the printer's AMS panel will not show custom (user / cloud) flow-dynamics profiles **unless** you have first visited *Calibration → Flow Dynamics → Manage Results* at least once in this BambuStudio session. Without that step, the AMS panel silently falls back to "Default" even when the printer actually has the right cali_idx applied — making it impossible to tell whether Bambuddy did its job or not.
->
-> **Use OrcaSlicer for these checks whenever possible.** OrcaSlicer's AMS panel reads the printer's calibration table directly and does not have this caveat.
->
-> If you must use BambuStudio, **open *Calibration → Flow Dynamics → Manage Results* once at the start of your session** (you can close it immediately afterwards), otherwise every "Verify (slicer)" step in this plan will give a false negative.
-
-### The verification protocol
-
-For each "Verify (slicer)" step:
-
-1. Open the slicer (OrcaSlicer preferred). Connect to the printer being tested.
-2. Open the **Device** tab → AMS panel for the right unit + slot.
-3. **Click the slot** to open the slot detail modal/panel.
-4. Confirm three things, in this order:
-   - **a) Filament preset name** — must match the spool's `slicer_filament_name` (or for cloud presets, the cloud preset name). Must **not** be "Default" or a fallback like "Generic PLA". Take a screenshot if it looks wrong before troubleshooting — race conditions are real.
-   - **b) K-profile (Flow Dynamics) selection** — must match the K-profile you assigned (by name or slot index). For "no stored K-profile" tests, the live cali_idx the slot already had should be preserved.
-   - **c) Re-open the modal** — close it and click the slot again. The values from (a) and (b) must persist. Flicker-to-Default or flicker-to-blank counts as a fail.
-5. If ANY of (a)/(b)/(c) is wrong, the row is a fail — even if Bambuddy's own UI looks correct.
-
-### Test both Bambu Lab and non-Bambu Lab spools
-
-Every "Verify (slicer)" row must be exercised with **both** of these spool kinds during a pass. Don't run all tests with only one kind:
-
-| Spool kind | Why it matters | What to use as the test spool |
-|---|---|---|
-| **Bambu Lab spool** with auto-detected RFID and cloud preset (`GFL05`, `GFA05`, etc.) | Tests the cloud preset lookup path, builtin filament_id mapping, and the "tray_info_idx already known" branch. | Any genuine Bambu PLA Basic / PETG HF / etc. spool with the original tag. |
-| **Non-Bambu Lab spool** assigned to a local or cloud user preset (`PFUS*`, `PFSP*`, or local-id integer slicer_filament) | Tests the user-preset path, the cloud-detail lookup fallback, and the K-profile resolution code that the recent fixes (`219bad76`, `8777dbea`, `b3aa8f3f`) target. This is where most regressions hide. | Polymaker / Sunlu / generic PLA with a custom slicer preset and a stored K-profile. Ideally one with calibration values that visibly differ from the Bambu spool above. |
-
-Within each section (B and C in particular), do the row once with the Bambu spool, then once with the non-Bambu spool. If results differ, **the result of the row is the worse of the two**, and the difference itself is something to file.
-
----
-
-## Pre-flight setup
-
-### Required environment
-- [ ] Bambuddy running, either built from branch `feature/spoolman-inventory-ui` **or** pulled from docker image `bambuddy:spoolman-test_20260505`
-- [ ] At least **two** Bambu Lab printers connected (single-printer setups miss multi-printer assignment bugs)
-- [ ] At least **one printer with a multi-AMS configuration** (dual AMS / AMS HT) — single-AMS users miss "wrong-AMS" routing
-- [ ] **OrcaSlicer** (preferred) installed on a machine that can reach the printers. BambuStudio acceptable but see the bug warning under [How to verify in the slicer](#how-to-verify-in-the-slicer).
-- [ ] (Spoolman mode only) Spoolman instance reachable, with at least 5 spools pre-populated
-
-### Required test spools
-You need physical spools of **both** kinds available for AMS-config tests in Sections B and C. Do not run those sections with only one kind:
-- [ ] At least **one Bambu Lab spool** with original RFID tag intact (auto-detected cloud preset path — `GFL05`, `GFA05`, etc.)
-- [ ] At least **one non-Bambu Lab spool** assigned to a custom slicer preset with a stored K-profile (Polymaker / Sunlu / Overture / generic PLA — exercises the user-preset path and the K-profile resolution that the recent fixes target)
-
-### Login + auth
-- [ ] Two user accounts exist: an **admin** and a **non-admin operator**
-- [ ] At least one **API key** created for the admin
-
-### Initial data state — capture before testing
-Take a snapshot so you can tell whether something changed unexpectedly:
-
-```bash
-# from your Bambuddy admin shell, or via the API:
-curl -s ${BAMBUDDY_URL}/api/v1/inventory/spools | jq 'length'
-curl -s ${BAMBUDDY_URL}/api/v1/inventory/assignments | jq 'length'
-curl -s ${BAMBUDDY_URL}/api/v1/spoolman/inventory/spools | jq 'length'   # spoolman mode
-curl -s ${BAMBUDDY_URL}/api/v1/spoolman/inventory/slot-assignments | jq 'length'
-```
-
-Record the counts. Re-check at the end of testing.
-
----
-
-## 0 — Pass entry sanity
-
-This section runs at the **start of each pass** to confirm you are in the right mode before any other testing. Do not flip Spoolman on/off in the middle of a pass.
-
-### 0a. Pass 1 entry (Local) — confirm Local mode
-
-| # | Step | Verify | Pass 1 (Local) |
-|---|---|---|---|
-| 0a.1 | Settings → Spoolman → confirm "Spoolman Enabled" is **OFF**, save if you had to change it | Toast confirms save (if changed). | _ |
-| 0a.2 | Fully reload the browser. Open `/inventory` | Page renders **local** spool list. Count matches local DB (`GET /api/v1/inventory/spools`). | _ |
-| 0a.3 | Settings → Spool Catalog tab | Shows **local** catalog table | _ |
-
-### 0b. Pass 2 entry (Spoolman) — confirm Spoolman mode
-
-| # | Step | Verify | Pass 2 (Spoolman) |
-|---|---|---|---|
-| 0b.1 | Settings → Spoolman → toggle ON, paste Spoolman URL, save | Toast confirms save. No console errors. | _ |
-| 0b.2 | Fully reload the browser. Open `/inventory` | Page renders **Spoolman** spool list. Count matches Spoolman (`GET /api/v1/spoolman/inventory/spools`). | _ |
-| 0b.3 | Break the Spoolman URL (`http://invalid:9999`), save, reload | Inventory page surfaces a clear error/disabled state — does **not** silently fall back to local | _ |
-| 0b.4 | Restore the Spoolman URL, save, reload | Spool list returns | _ |
-| 0b.5 | Settings → Spool Catalog tab | Shows **Spoolman filament table** (not local catalog) | _ |
-
-**Stop and file a bug if 0a or 0b fail for the pass you are in.** Mode-switch is the foundation; everything downstream is invalid otherwise.
-
----
-
-## A — Inventory page (`/inventory`)
-
-### A1. Spool list rendering
-
-| # | Step | Verify (UI) | Verify (DB / API) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|
-| A1.1 | Open `/inventory` | Tabs visible: Spools, Assignments. Spool count chip is correct. | List length matches `GET /inventory/spools` (or `/spoolman/inventory/spools`) | _ | _ |
-| A1.2 | Each spool card shows: color swatch, name, material, brand, weight bar | Color swatch reflects rgba/extra_colors. Multi-color shows gradient. Sparkle effect renders for sparkle spools. | — | _ | _ |
-| A1.3 | Hover a spool card → FilamentHoverCard | Shows nozzle temp range, K-profiles list, slicer preset name | — | _ | _ |
-| A1.4 | Filter by material (PLA / PETG / ABS chip) | Only matching spools remain | — | _ | _ |
-| A1.5 | Search by name / RFID UID | Match works for both substrings | — | _ | _ |
-| A1.6 | Sort by Recent / Name / Weight | Ordering correct in both directions | — | _ | _ |
-
-### A2. Spool CRUD
-
-| # | Step | Verify (UI) | Verify (DB) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|
-| A2.1 | Click **+ New Spool** → SpoolFormModal opens | Form has: name, material, brand, color picker, weight, NFC fields, K-profile section, **slicer preset picker** | — | _ | _ |
-| A2.2 | (Local) Fill all fields, click **Pick from Catalog** → catalog modal opens | Catalog list shown. Picking entry pre-fills name/weight. | catalog row referenced | _ | N/A |
-| A2.3 | (Spoolman) **Pick from Filament Catalog** → Spoolman filament table shown | Selecting a Spoolman filament pre-fills material/brand/color | uses Spoolman filament_id | N/A | _ |
-| A2.4 | Save new spool | Toast "Spool created". Card appears in list. | row exists | _ | _ |
-| A2.5 | Edit spool — change weight, save | Weight bar updates, card re-renders | row updated | _ | _ |
-| A2.6 | Edit spool — change `storage_location`, save | Field persists across reload — **no round-trip duplication** (regression check) | column persists exactly | _ | _ |
-| A2.7 | Edit spool — set NFC `tag_uid` to 14-char hex, save | Saves OK (column was widened to VARCHAR(32)) | persisted | _ | _ |
-| A2.8 | Edit spool — set color to multi-color (2+ extra_colors), save | Swatch shows gradient | extra_colors persisted | _ | _ |
-| A2.9 | Archive spool | Card disappears from default view; appears under "Archived" filter | `archived=true` | _ | _ |
-| A2.10 | Restore archived spool | Card returns to active list | `archived=false` | _ | _ |
-| A2.11 | Delete spool (active, no assignments) | Confirmation prompt → row removed | row gone | _ | _ |
-| A2.12 | Delete spool **currently assigned to AMS** | Either prevents delete or unassigns first — **must not silently leave a dangling assignment** | no orphan assignment row | _ | _ |
-
-### A3. Catalog management
-
-The catalog UI is mode-aware: Local mode shows the local catalog; Spoolman mode shows the Spoolman filament table. Section 0a.3 / 0b.5 already covered "the right table is shown for this pass" — A3 covers editing.
-
-| # | Step | Verify | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| A3.1 | Open Settings → Spool Catalog | (Local) catalog table editable. (Spoolman) filament table editable. | _ | _ |
-| A3.2 | Edit a catalog/filament entry's name and `spool_weight`, save | Changes persist; spools using that entry pick up new spool_weight (regression — `28fa66a3` "stamp on apply to all") | _ | _ |
-| A3.3 | Add a new catalog/filament entry | Saves and is selectable in SpoolFormModal | _ | _ |
-| A3.4 | Delete a catalog/filament entry not in use | Removes cleanly | _ | _ |
-
-### A4. K-profile section in SpoolFormModal
-
-| # | Step | Verify (UI) | Verify (slicer) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|
-| A4.1 | Edit spool → K-Profiles section | List of stored K-profiles per (printer, nozzle, extruder) | — | _ | _ |
-| A4.2 | Add a K-profile row: pick printer, set nozzle 0.4, K-value 0.020, slot_id 5 | Saves; row visible after reload | row in `k_profile` table | _ | _ |
-| A4.3 | Edit existing K-profile, change cali_idx (slot_id) | Updates without creating duplicate | row updated, no second row | _ | _ |
-| A4.4 | Delete K-profile | Removed from list | row gone | _ | _ |
-
-### A5. Assignments tab
-
-| # | Step | Verify | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| A5.1 | Switch to Assignments tab | Each row shows: spool name, **printer_name**, **AMS label** ("AMS-A", "HT-A", "External"), tray ID | _ | _ |
-| A5.2 | Click "View in printer card" on an assignment | Routes to `/printers` and opens that printer's card | _ | _ |
-| A5.3 | Unassign from row | Assignment disappears; spool returns to unassigned pool | _ | _ |
-
-### A6. Deep-link from external context
-
-| # | Step | Verify | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| A6.1 | Click a spool from the printer-card hover (deep-link) | `/inventory?spool=<id>` opens with that spool **scrolled into view + highlighted** | _ | _ |
-| A6.2 | Deep-link to a spool that's archived | Page surfaces it (auto-includes archived) | _ | _ |
-
----
-
-## B — Printer card AMS slot cards (`/printers`)
-
-These tests exercise the AMS slot UI, the AssignSpoolModal, and the ConfigureAmsSlotModal — and the K-profile cascade work. **The slicer-verification step is the most important part of this section.**
-
-> **Reminder before you start B:** every "Verify (slicer)" row must be run **twice** — once with a Bambu Lab spool (cloud preset path), once with a non-Bambu Lab spool (user-preset path). See [How to verify in the slicer](#how-to-verify-in-the-slicer) for the protocol. Use OrcaSlicer if at all possible.
-
-### B1. AMS slot rendering on printer card
-
-| # | Step | Verify (UI) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| B1.1 | Open `/printers`, expand a printer card | All AMS units shown with correct count of slots (4 per regular AMS, 1 for HT, 2 for External/VT) | _ | _ |
-| B1.2 | Each slot shows: color, material/type label, weight % bar, K-profile indicator if set | Colors match the loaded filament's RGBA. Empty slots are visually distinct. | _ | _ |
-| B1.3 | Hover a configured slot → FilamentHoverCard | Shows preset name, K-value, calibration source, **printer_name + AMS label** | _ | _ |
-| B1.4 | Multi-printer setup: each printer's AMS only shows assignments for that printer | No cross-contamination | _ | _ |
-
-### B2. Assign spool to a **loaded** AMS slot (immediate-apply path)
-
-> Loaded slot = a slot the printer already reports filament in. Assignment fires MQTT immediately.
-
-| # | Step | Verify (UI) | **Verify (slicer) — MANDATORY** | Verify (DB) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|---|
-| B2.1 | Click an empty-of-assignment but **physically loaded** AMS slot | AssignSpoolModal opens. Title shows correct AMS label + tray ID. | — | — | _ | _ |
-| B2.2 | Spool list in the modal | **Already-assigned spools are excluded** (regression check — bug we fixed) | — | — | _ | _ |
-| B2.3 | Pick a spool with a K-profile already stored for this printer/nozzle/extruder, confirm | Toast "Assigned!" closes within ~1.5s | Open BambuStudio → Device → AMS panel → click the slot → modal shows: <br>• preset name = the spool's slicer_filament_name (NOT "Default") <br>• K-value matches the spool's K-profile <br>• cali_idx matches the K-profile's slot_id | `SpoolAssignment` row created with non-empty `fingerprint_type`; `configured=true`; `pending_config=false` | _ | _ |
-| B2.4 | After B2.3, **close and reopen the slot detail modal in BambuStudio** | Same preset / K-value / cali_idx persists across re-open (no flicker to "Default") | — | — | _ | _ |
-| B2.5 | Pick a spool with **no** K-profile stored for this slot, confirm | Toast "Assigned!" | Slicer slot detail shows live cali_idx preserved (i.e. not reset to -1 / "Default") **if the slot already had a calibration** | row created; `extrusion_cali_sel` was published | _ | _ |
-| B2.6 | Pick a spool whose `slicer_filament` is a PFUS\* cloud preset | Toast OK | Slicer shows the **cloud preset name**, not a generic fallback | tray_info_idx resolved via cloud lookup (check logs) | _ | _ |
-| B2.7 | Pick a spool whose K-profile cascades from RFID tag scan | K-profile auto-populates from tag, persists after assignment | Slicer cali_idx matches the cascaded K-profile (regression — Phase 13 fix) | k_profile row + assignment both correct | _ | _ |
-
-### B3. Assign spool to an **empty** AMS slot (deferral / SpoolBuddy primary workflow)
-
-> Empty slot = printer reports `tray_type=""`. Bambu firmware silently drops MQTT for these, so Bambuddy persists the assignment and replays MQTT when the spool is physically inserted.
-
-| # | Step | Verify (UI) | **Verify (slicer)** | Verify (DB) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|---|
-| B3.1 | Click an empty AMS slot, pick spool, confirm | Toast: **"Assigned. Slot will configure when you insert the spool."** Modal closes after ~2.5s. | Slicer slot is still empty / unconfigured (correct — nothing was published yet) | row created with empty fingerprint_type; `pending_config=true` | _ | _ |
-| B3.2 | Now physically insert filament into that slot. Wait for AMS state push (~3–5s) | Slot card UI updates with material/color from the live tray | Slicer slot detail shows: spool's preset name, K-value, cali_idx — **as if it had been an immediate-apply assign** | `SpoolAssignment.fingerprint_type` now stamped; second `ams_set_filament_setting` published in logs | _ | _ |
-| B3.3 | After B3.2, push another AMS update (e.g. wait 30s for next telemetry) | Slot card stable | **MQTT does NOT re-fire** (logs show it was skipped because fingerprint already stamped) | no duplicate publish in logs | _ | _ |
-
-### B4. Configure-Slot modal (independent of assignment)
-
-> Right-click slot → "Configure slot" — used to set/change preset+K-profile without changing the assigned spool.
-
-| # | Step | Verify (UI) | **Verify (slicer) — MANDATORY** | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|
-| B4.1 | Right-click a configured slot → Configure | ConfigureAmsSlotModal opens with preset and K-profile pre-filled from current state | — | _ | _ |
-| B4.2 | Change preset to a different one with stored K-profile, save | Toast OK; modal closes | Slicer shows new preset name + new K-value + new cali_idx **all aligned**. Re-open slicer modal — values persist. | _ | _ |
-| B4.3 | Change preset to one **without** stored K-profile, save | Toast OK | Slicer shows new preset; cali_idx falls back to current live cali_idx (not zero, not "Default") | _ | _ |
-| B4.4 | Change K-profile (slot_id) to a different cali index, save | Toast OK | Slicer cali_idx matches the new slot_id within ~1s | _ | _ |
-| B4.5 | Apply config to **slot with empty AMS tray** (tray_type=="") | Backend behaviour mirrors B3 — pending until insert | After insert, slicer reflects the configured preset + K-profile | _ | _ |
-| B4.6 | (REGRESSION) Try a path where K-profile **silently dropped to default** previously | K-profile from form is what ends up in slicer — never silently zeroed. (Fix #219bad76, three apply paths.) Test via: assign spool with K-profile → ConfigureSlot opens with K-profile filled → save without changing anything → confirm cali_idx **unchanged** in slicer. | _ | _ |
-
-### B5. Unassign spool from slot
-
-| # | Step | Verify (UI) | **Verify (slicer)** | Verify (DB) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|---|
-| B5.1 | Right-click assigned slot → Unassign | Card returns to "no spool linked" state | Slicer side: previous preset / cali_idx remain (we don't actively clear printer state — just our DB) | `SpoolAssignment` row removed | _ | _ |
-| B5.2 | After unassign, hover the slot | No "linked spool" info shown | — | — | _ | _ |
-| B5.3 | (UI tidy) "Unassign" button is hidden in places where it would be redundant (regression check on the Printers page action menu) | No duplicate "Open in Inventory" or "Unassign" entries | — | — | _ | _ |
-
-### B6. AMS labelling
-
-| # | Step | Verify | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| B6.1 | Standard AMS unit IDs 0–3 → labels "AMS-A" through "AMS-D" | — | _ | _ |
-| B6.2 | HT AMS IDs 128–135 → labels "HT-A" through "HT-H" | — | _ | _ |
-| B6.3 | External / VT slot (id 254/255) → "External" | — | _ | _ |
-| B6.4 | User-edited AMS friendly name → shows on hover card and assignment list | — | _ | _ |
-
----
-
-## C — SpoolBuddy frontend (`/spoolbuddy`)
-
-These tests run on a **paired SpoolBuddy device** (kiosk on a Pi or a desktop browser pointed at `/spoolbuddy`). Same Local-vs-Spoolman pass.
-
-> **Reminder before you start C:** rows that touch AMS slot config (especially C4 weigh-and-assign and C5 AMS page) must be run with **both** a Bambu Lab spool and a non-Bambu Lab spool, and verified in the slicer per [How to verify in the slicer](#how-to-verify-in-the-slicer). Use OrcaSlicer if at all possible.
-
-### C1. Dashboard rendering
-
-| # | Step | Verify | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| C1.1 | Open `/spoolbuddy` | Top bar: connection state, mode chip ("Local" or "Spoolman" — unified label, regression check) | _ | _ |
-| C1.2 | Quick menu / bottom nav | Tabs: Dashboard, Inventory, AMS, Calibration, Settings | _ | _ |
-| C1.3 | Status bar shows weight reading | If scale absent, shows clear "scale not connected" — not a silent zero | _ | _ |
-
-### C2. NFC tag flow — link to spool
-
-| # | Step | Verify (UI) | Verify (DB) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|
-| C2.1 | Place a Bambu RFID tag on the reader | TagDetectedModal opens, shows tag UID + auto-decoded material/color | scanned UID logged | _ | _ |
-| C2.2 | (Bambu auto-detected tag, never linked) "Assign to AMS" button is **disabled** with explanation tooltip (regression check) | — | — | _ | _ |
-| C2.3 | Click **Link to existing spool** → spool list opens | Search works; can select | — | _ | _ |
-| C2.4 | Confirm link | SpoolInfoCard appears + success toast (regression — `d8811a77`) | spool's `tag_uid` (NOT bambu tray_type code) updated (regression — `be48c60e`) | _ | _ |
-| C2.5 | Place same tag again | Modal opens at the linked-spool view directly (no re-link prompt) | — | _ | _ |
-| C2.6 | (Spoolman) link a non-Bambu NFC tag (14-hex-char UID) | Saves OK (column widening regression) | tag_uid persisted | N/A | _ |
-
-### C4. Weigh-and-assign workflow
-
-| # | Step | Verify (UI) | **Verify (slicer)** | Verify (DB) | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|---|---|
-| C4.1 | Place spool on scale, place its tag | Live weight readout updates; spool info card shown | — | — | _ | _ |
-| C4.2 | (Regression) Negative scale reading shown when tare not yet applied | Doesn't crash; shows the negative number rather than zero-clamping (`05d03062`) | — | — | _ | _ |
-| C4.3 | Click "Assign to AMS" → AssignToAmsModal opens | Lists AMS slots across all reachable printers; **disabled for already-assigned spools** with clear tooltip (`f3a475ca`) | — | — | _ | _ |
-| C4.4 | Pick an empty AMS slot → confirm | Toast: "Assigned. Slot will configure when you insert the spool." | Slicer empty (correct — pending) | row with `pending_config=true` | _ | _ |
-| C4.5 | Insert spool into slot | After AMS push, full configuration replayed | **Slicer slot detail shows correct preset + K-value + cali_idx** | fingerprint stamped; full publish in logs | _ | _ |
-| C4.6 | Pick a **loaded** AMS slot → confirm | Toast: "Assigned!" | Slicer immediately reflects new spool's preset + K-profile | row with `pending_config=false` | _ | _ |
-
-### C5. SpoolBuddy AMS page
-
-| # | Step | Verify | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| C5.1 | Open AMS page | All paired printers listed; tap a printer → its AMS units shown | _ | _ |
-| C5.2 | Tap a slot card | Same Configure-Slot modal as desktop (or unified equivalent) | _ | _ |
-| C5.3 | Apply changes from SpoolBuddy AMS page | Same effect + slicer visibility as B4 | _ | _ |
-
-### C6. SpoolBuddy inventory page
-
-| # | Step | Verify | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| C6.1 | Open Inventory page in SpoolBuddy | Spool list mirrors desktop /inventory (same backend, same mode) | _ | _ |
-| C6.2 | (Regression) UI labels on Local vs Spoolman views are unified — same wording, no mode-specific divergence (#`05d819d1`) | "Spool weight", "Storage location", "Tag UID" reads identical in both | _ | _ |
-| C6.3 | Edit spool from SpoolBuddy → save | Reflects in desktop /inventory after refresh | _ | _ |
-
----
-
-## D — Cross-cutting / specific regression cases
-
-These are bugs the recent fix commits addressed. **If any of these reproduces, file a P0.** The tests are written from the user's perspective — no backend knowledge needed.
-
-| # | Regression | What to do | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| D1 | K-profile silently lost when assigning a spool | Assign a spool that has a stored K-profile (visible in the spool's edit form). Open OrcaSlicer's slot detail for that AMS slot — the **Flow Dynamics** value must show the spool's K-profile name, **not** "Default" or empty. | _ | _ |
-| D2 | Changes from Configure-Slot don't stick | Right-click a slot → Configure → pick a different K-profile → Save. In OrcaSlicer's slot detail, **close and re-open** the modal — the K-profile must be the new one, not flicker back to a different value. | _ | _ |
-| D3 | Custom-preset spool loses both preset and K-profile in slicer | Assign a spool that uses a **custom slicer preset** (one you imported into BambuStudio yourself, *not* a Bambu factory preset). In the slicer's slot detail, **both** the preset name **and** the K-profile must show the spool's values, not a generic fallback like "Generic PLA" / "Default". | _ | _ |
-| D4 | Already-assigned spools shown in the picker | Open AssignSpoolModal on any AMS slot → the spool list must **exclude** spools that are already assigned to another slot. | _ | _ |
-| D5 | Save hangs when removing extra colors from a multi-color spool | Edit a multi-color spool → remove all the extra colors so it becomes a single-color spool → click Save. Save must complete (no spinner that runs forever). | _ | _ |
-| D6 | RFID tag scan doesn't bring its K-profile across | Scan a Bambu RFID tag (or a linked NFC-tagged spool) that has stored K-profiles → the spool form's K-Profiles section must auto-populate from the tag, with no manual entry needed. | _ | _ |
-| D7 | Labels and wording differ between Local and Spoolman modes | After completing both passes, compare the same form / modal side-by-side in each mode — labels must read the same (e.g. "Spool weight", "Storage location", "Tag UID"). | _ | _ |
-| D8 | Assignments tab missing printer name and AMS label | Inventory → Assignments tab → each row must display the printer's name (e.g. "X1C-living-room") and the AMS label (e.g. "AMS-A", "HT-A", "External") — not just bare numbers. | _ | _ |
-| D9 | Storage location modified silently on save | Edit a spool → set "Storage location" to exactly `Shelf 3, slot B` → Save → reload → Edit again. The field must read **exactly** `Shelf 3, slot B` — no extra spaces, no quote characters, no duplication. | _ | _ |
-| D10 | Bulk weight update doesn't apply to all spools | In the catalog (or Spoolman filament list), edit an entry's spool_weight → Save / Apply → return to inventory. **Every** spool linked to that catalog entry must show the new weight, not just the most recent one. | _ | _ |
-| D11 | Spoolman on a private LAN IP is rejected | Set the Spoolman URL to a private LAN address (e.g. `http://192.168.1.50:7912` or `http://10.0.0.20:7912`) → Save → reload. Spoolman pages must load. (Earlier builds blocked private IPs as a security measure; this confirms the fix.) | N/A | _ |
-| D12 | API key can no longer read settings *(skip if you don't use API keys)* | Settings → API Keys → create a key. From a terminal: `curl -H "Authorization: Bearer <key>" <bambuddy-url>/api/v1/settings` — must return the settings JSON, not "403 Forbidden". | _ | _ |
-
----
-
-## E — Multi-user / permission tests
-
-Run with the **non-admin operator** account.
-
-| # | Step | Expected | Pass 1 (Local) | Pass 2 (Spoolman) |
-|---|---|---|---|---|
-| E1 | View `/inventory` | Allowed (read) | _ | _ |
-| E2 | Create / edit / delete spool | Allowed if has `INVENTORY_UPDATE`; 403 if not | _ | _ |
-| E3 | Assign spool to AMS | Allowed if has `INVENTORY_UPDATE`; 403 if not | _ | _ |
-| E4 | Configure slot via Configure-Slot modal | Allowed only with proper permission | _ | _ |
-| E5 | (API key) Read settings via API key | 200 (regression — was 403) | _ | _ |
-
----
-
-## F — Final state diff
-
-Run this **once** after Pass 2 completes (i.e. after both passes are done). Re-run the snapshot from Pre-flight and verify:
-
-- [ ] Local spool count change matches what you intentionally created
-- [ ] Local assignment count is back to baseline (or matches your final intentional state)
-- [ ] Spoolman counts likewise
-- [ ] No orphan rows: `SELECT COUNT(*) FROM spool_assignment WHERE spool_id NOT IN (SELECT id FROM spool)` should be 0
-- [ ] No leftover `pending_config` rows: assignments where you completed the insert step have `fingerprint_type IS NOT NULL`
-
----
-
-## How to file a failure
-
-For each F row, please post a comment on issue **TBD** with:
-- The row number (e.g. **B2.4**)
-- One-line description of what you observed vs expected
-- Bambuddy version (`/api/v1/version`)
-- Printer model + firmware
-- Slicer + version
-- A screenshot of the slicer's slot detail modal (for any B/C section failure)
-- Bambuddy logs from the relevant 60s window (`docker logs bambuddy --since 1m`)
-
----
-
-## Skip / N/A guidance
-
-- **No SpoolBuddy hardware:** skip Section C entirely in both passes. Note this at the top of your report.
-- **No Spoolman instance:** skip Pass 2 (Spoolman) entirely, plus row D11. Run only Pass 1 (Local).
-- **Single AMS unit:** skip B6, but still run B1–B5 on that one unit (in both passes).
-- **Single printer:** skip B1.4 in both passes. Still run all per-printer tests on the one you have.
-- **No NFC reader:** skip C2 in both passes.
-
----
-
-*End of plan. ~120 distinct verification points, ~80 of which require slicer-side confirmation. Yes, this is a lot — that is the point.*

+ 0 - 45
frontend/src/__tests__/components/AdditionalSection.test.tsx

@@ -1,45 +0,0 @@
-import React from 'react';
-import { describe, it, expect, vi } from 'vitest';
-import { screen } from '@testing-library/react';
-import { render } from '../utils';
-import { AdditionalSection } from '../../components/spool-form/AdditionalSection';
-import { defaultFormData } from '../../components/spool-form/types';
-
-vi.mock('react-i18next', () => ({
-  useTranslation: () => ({
-    t: (key: string) => key,
-  }),
-}));
-
-const baseProps = {
-  formData: defaultFormData,
-  updateField: vi.fn(),
-  spoolCatalog: [],
-  currencySymbol: '$',
-  availableCategories: [],
-  globalLowStockThreshold: 20,
-};
-
-describe('AdditionalSection', () => {
-  it('renders SpoolWeightPicker when spoolmanMode is false', () => {
-    render(<AdditionalSection {...baseProps} spoolmanMode={false} />);
-    // SpoolWeightPicker renders the 'inventory.coreWeight' label
-    expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
-    // Info notice must NOT be present
-    expect(screen.queryByText('inventory.spoolWeightManagedBySpoolman')).toBeNull();
-  });
-
-  it('hides SpoolWeightPicker and shows info notice when spoolmanMode is true', () => {
-    render(<AdditionalSection {...baseProps} spoolmanMode={true} />);
-    // Info notice must appear
-    expect(screen.getByText('inventory.spoolWeightManagedBySpoolman')).toBeTruthy();
-    // SpoolWeightPicker must NOT be rendered
-    expect(screen.queryByText('inventory.coreWeight')).toBeNull();
-  });
-
-  it('defaults to spoolmanMode=false when prop is omitted', () => {
-    render(<AdditionalSection {...baseProps} />);
-    // SpoolWeightPicker present by default
-    expect(screen.getByText('inventory.coreWeight')).toBeTruthy();
-  });
-});

+ 0 - 71
frontend/src/__tests__/components/AssignSpoolModal.test.tsx

@@ -9,7 +9,6 @@ vi.mock('../../api/client', () => ({
     getSpools: vi.fn(),
     getAssignments: vi.fn(),
     assignSpool: vi.fn(),
-    getSpoolmanInventorySpools: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
   },
@@ -286,73 +285,3 @@ describe('AssignSpoolModal', () => {
     });
   });
 });
-
-describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
-  const spoolmanSpool = {
-    id: 200,
-    material: 'PETG',
-    subtype: 'HF',
-    brand: 'Bambu Lab',
-    color_name: 'Blue',
-    rgba: '0000FFFF',
-    label_weight: 1000,
-    weight_used: 0,
-    tag_uid: null,
-    tray_uuid: null,
-    archived_at: null,
-  };
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([]);
-    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
-    (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool]);
-  });
-
-  it('shows Spoolman spool section when spoolmanEnabled=true', async () => {
-    render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
-
-    await waitFor(() => {
-      // Spoolman spool brand should appear in the modal
-      expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
-    });
-    expect(api.getSpoolmanInventorySpools).toHaveBeenCalledWith(false);
-  });
-
-  it('does not fetch Spoolman spools when spoolmanEnabled=false', async () => {
-    render(<AssignSpoolModal {...defaultProps} spoolmanEnabled={false} />);
-
-    // Give the component time to settle
-    await waitFor(() => {
-      expect(api.getSpools).toHaveBeenCalled();
-    });
-    expect(api.getSpoolmanInventorySpools).not.toHaveBeenCalled();
-  });
-
-  it('hides local spool list when spoolmanEnabled=true (Bug #5)', async () => {
-    // Even when local spools exist, they must not appear in Spoolman mode.
-    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
-
-    render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
-
-    await waitFor(() => {
-      // Spoolman spool is shown
-      expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
-    });
-    // Local spool (Polymaker) must NOT appear in Spoolman mode
-    expect(screen.queryByText(/Polymaker/)).not.toBeInTheDocument();
-  });
-
-  it('hides archived Spoolman spools', async () => {
-    const archivedSpool = { ...spoolmanSpool, id: 201, brand: 'Prusa', archived_at: '2025-01-01T00:00:00Z' };
-    (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool, archivedSpool]);
-
-    render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
-    });
-    // Archived spool brand must NOT appear
-    expect(screen.queryByText(/Prusa/)).not.toBeInTheDocument();
-  });
-});

+ 0 - 251
frontend/src/__tests__/components/AssignToAmsModal.test.tsx

@@ -1,251 +0,0 @@
-/**
- * Tests for AssignToAmsModal — verifies that spoolmanMode prop
- * routes to assignSpoolmanSlot (not assignSpool) when assigning.
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-
-vi.mock('../../api/client', () => ({
-  api: {
-    getPrinterStatus: vi.fn(),
-    getPrinter: vi.fn(),
-    getSettings: vi.fn().mockResolvedValue({}),
-    assignSpool: vi.fn(),
-    assignSpoolmanSlot: vi.fn(),
-    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
-    getAssignments: vi.fn().mockResolvedValue([]),
-    getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
-  },
-}));
-
-import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
-import { api } from '../../api/client';
-
-const SPOOL = {
-  id: 42,
-  material: 'PLA',
-  subtype: 'Basic',
-  brand: 'BrandX',
-  color_name: 'Red',
-  rgba: 'FF0000FF',
-  label_weight: 1000,
-  weight_used: 0,
-  tag_uid: null,
-  tray_uuid: null,
-  slicer_filament_name: 'PLA',
-  data_origin: 'spoolman',
-  k_profiles: [],
-};
-
-const BLANK_TRAY = {
-  tray_color: null,
-  tray_type: null,
-  tray_sub_brands: null,
-  tray_id_name: null,
-  tray_info_idx: null,
-  remain: 100,
-  k: null,
-  cali_idx: null,
-  tag_uid: null,
-  tray_uuid: null,
-  nozzle_temp_min: null,
-  nozzle_temp_max: null,
-  drying_temp: null,
-  drying_time: null,
-  state: null,
-};
-
-const PRINTER_STATUS_ONLINE = {
-  connected: true,
-  state: 'idle',
-  ams: [
-    {
-      id: 0,
-      humidity: null,
-      temp: null,
-      is_ams_ht: false,
-      serial_number: '',
-      sw_ver: '',
-      dry_time: 0,
-      dry_status: 0,
-      dry_sub_status: 0,
-      tray: [
-        { id: 0, ...BLANK_TRAY },
-        { id: 1, ...BLANK_TRAY },
-        { id: 2, ...BLANK_TRAY },
-        { id: 3, ...BLANK_TRAY },
-      ],
-    },
-  ],
-  nozzles: [{ nozzle_diameter: '0.4', nozzle_type: 'stainless' }],
-  ams_extruder_map: { '0': 0 },
-  dual_nozzle: false,
-};
-
-describe('AssignToAmsModal', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-    vi.mocked(api.getPrinterStatus).mockResolvedValue(PRINTER_STATUS_ONLINE as never);
-    vi.mocked(api.getPrinter).mockResolvedValue({ id: 1, name: 'Test Printer' } as never);
-    vi.mocked(api.assignSpool).mockResolvedValue({} as never);
-    vi.mocked(api.assignSpoolmanSlot).mockResolvedValue({} as never);
-  });
-
-  it('renders modal when open', async () => {
-    render(
-      <AssignToAmsModal
-        isOpen={true}
-        onClose={vi.fn()}
-        spool={SPOOL as never}
-        printerId={1}
-        spoolmanMode={false}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText(/Assign.*to AMS/i)).toBeInTheDocument();
-    });
-  });
-
-  it('renders nothing when closed', () => {
-    render(
-      <AssignToAmsModal
-        isOpen={false}
-        onClose={vi.fn()}
-        spool={SPOOL as never}
-        printerId={1}
-        spoolmanMode={false}
-      />
-    );
-
-    expect(screen.queryByText(/Assign.*to AMS/i)).not.toBeInTheDocument();
-  });
-
-  describe('API routing based on spoolmanMode', () => {
-    it('calls assignSpool when spoolmanMode is false', async () => {
-      const user = userEvent.setup();
-      render(
-        <AssignToAmsModal
-          isOpen={true}
-          onClose={vi.fn()}
-          spool={SPOOL as never}
-          printerId={1}
-          spoolmanMode={false}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
-      });
-
-      // Click first available slot button
-      const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
-      await user.click(slotButtons[0]);
-      await waitFor(() => {
-        expect(api.assignSpool).toHaveBeenCalledWith(
-          expect.objectContaining({ spool_id: 42, printer_id: 1 })
-        );
-      });
-      expect(api.assignSpoolmanSlot).not.toHaveBeenCalled();
-    });
-
-    it('calls assignSpoolmanSlot when spoolmanMode is true', async () => {
-      const user = userEvent.setup();
-      render(
-        <AssignToAmsModal
-          isOpen={true}
-          onClose={vi.fn()}
-          spool={SPOOL as never}
-          printerId={1}
-          spoolmanMode={true}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
-      });
-
-      const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
-      await user.click(slotButtons[0]);
-      await waitFor(() => {
-        expect(api.assignSpoolmanSlot).toHaveBeenCalledWith(
-          expect.objectContaining({ spoolman_spool_id: 42, printer_id: 1 })
-        );
-      });
-      expect(api.assignSpool).not.toHaveBeenCalled();
-    });
-  });
-
-  describe('query invalidation after successful assign', () => {
-    it('calls assignSpoolmanSlot successfully — invalidation fires on success', async () => {
-      const user = userEvent.setup();
-      vi.mocked(api.assignSpoolmanSlot).mockResolvedValue({} as never);
-
-      render(
-        <AssignToAmsModal
-          isOpen={true}
-          onClose={vi.fn()}
-          spool={SPOOL as never}
-          printerId={1}
-          spoolmanMode={true}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.queryAllByTitle(/AMS Slot/i).length).toBeGreaterThan(0);
-      });
-
-      const slotButtons = screen.queryAllByTitle(/AMS Slot/i);
-      await user.click(slotButtons[0]);
-      await waitFor(() => {
-        expect(api.assignSpoolmanSlot).toHaveBeenCalledWith(
-          expect.objectContaining({ spoolman_spool_id: 42, printer_id: 1 })
-        );
-      });
-    });
-  });
-
-  describe('slot highlighting', () => {
-    it('shows no ring-bambu-green when spool is not assigned', async () => {
-      vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValue([]);
-      const { container } = render(
-        <AssignToAmsModal
-          isOpen={true}
-          onClose={vi.fn()}
-          spool={SPOOL as never}
-          printerId={1}
-          spoolmanMode={true}
-        />
-      );
-
-      await waitFor(() => {
-        expect(screen.queryAllByRole('button').length).toBeGreaterThan(0);
-      });
-
-      expect(container.querySelector('.ring-bambu-green')).toBeNull();
-    });
-
-    it('highlights assigned slot with ring-bambu-green when spool is assigned to tray 2', async () => {
-      vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValue([
-        { printer_id: 1, ams_id: 0, tray_id: 2, spoolman_spool_id: 42 },
-      ] as never);
-
-      const { container } = render(
-        <AssignToAmsModal
-          isOpen={true}
-          onClose={vi.fn()}
-          spool={SPOOL as never}
-          printerId={1}
-          spoolmanMode={true}
-        />
-      );
-
-      await waitFor(() => {
-        expect(container.querySelector('.ring-bambu-green')).toBeInTheDocument();
-      });
-    });
-  });
-});

+ 30 - 137
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -74,44 +74,63 @@ describe('FilamentHoverCard', () => {
     });
   });
 
-  describe('fill source badge transparency (#11)', () => {
-    it('never shows a Spoolman-source badge — UI stays mode-agnostic', async () => {
+  describe('Spoolman source indicator', () => {
+    it('shows Spoolman label when fillSource is spoolman', async () => {
       renderWithHover(
         <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'spoolman' }}>
           <div>trigger</div>
         </FilamentHoverCard>
       );
+
       vi.advanceTimersByTime(100);
+
       await waitFor(() => {
-        expect(screen.getByText('80%')).toBeInTheDocument();
-        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
+        expect(screen.getByText('(Spoolman)')).toBeInTheDocument();
       });
     });
 
-    it('never shows an inventory-source badge — UI stays mode-agnostic', async () => {
+    it('does not show Spoolman label when fillSource is ams', async () => {
       renderWithHover(
-        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'inventory' }}>
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 80, fillSource: 'ams' }}>
           <div>trigger</div>
         </FilamentHoverCard>
       );
+
       vi.advanceTimersByTime(100);
+
       await waitFor(() => {
         expect(screen.getByText('80%')).toBeInTheDocument();
-        expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
+        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
       });
     });
 
-    it('does not render an empty source-label span when fillLevel is null', async () => {
+    it('does not show Spoolman label when fillLevel is null', async () => {
       renderWithHover(
         <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: null, fillSource: 'spoolman' }}>
           <div>trigger</div>
         </FilamentHoverCard>
       );
+
       vi.advanceTimersByTime(100);
+
       await waitFor(() => {
         expect(screen.getByText('—')).toBeInTheDocument();
         expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
-        expect(screen.queryByText('(Inv)')).not.toBeInTheDocument();
+      });
+    });
+
+    it('does not show Spoolman label when fillSource is undefined', async () => {
+      renderWithHover(
+        <FilamentHoverCard data={{ ...baseFilamentData, fillLevel: 50 }}>
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+
+      vi.advanceTimersByTime(100);
+
+      await waitFor(() => {
+        expect(screen.getByText('50%')).toBeInTheDocument();
+        expect(screen.queryByText('(Spoolman)')).not.toBeInTheDocument();
       });
     });
   });
@@ -212,104 +231,6 @@ describe('FilamentHoverCard', () => {
         expect(screen.getByText(/assign/i)).toBeInTheDocument();
       });
     });
-
-    it('shows the assign-spool button as disabled when isAssigned=true', async () => {
-      const onAssign = vi.fn();
-      renderWithHover(
-        <FilamentHoverCard
-          data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
-          inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
-        >
-          <div>trigger</div>
-        </FilamentHoverCard>
-      );
-      vi.advanceTimersByTime(100);
-      await waitFor(() => {
-        expect(screen.getByText(/assign/i)).toBeInTheDocument();
-        expect(screen.getByText(/assign/i).closest('button')).toBeDisabled();
-      });
-    });
-
-    it('does not call onAssignSpool when the button is disabled via isAssigned', async () => {
-      const onAssign = vi.fn();
-      renderWithHover(
-        <FilamentHoverCard
-          data={{ ...baseFilamentData, vendor: 'Bambu Lab' }}
-          inventory={{ assignedSpool: null, onAssignSpool: onAssign, isAssigned: true }}
-        >
-          <div>trigger</div>
-        </FilamentHoverCard>
-      );
-      vi.advanceTimersByTime(100);
-      await waitFor(() => expect(screen.getByText(/assign/i)).toBeInTheDocument());
-      const btn = screen.getByText(/assign/i).closest('button')!;
-      btn.click();
-      expect(onAssign).not.toHaveBeenCalled();
-    });
-  });
-
-  // For RFID-synced BL spools, both spoolman.linkedSpoolId and
-  // inventory.assignedSpool.id point to the same Spoolman spool. Rendering
-  // both branches gave two identical "Open in Inventory" buttons. The
-  // inventory-side button is suppressed when it would duplicate the
-  // spoolman-side link.
-  describe('"Open in Inventory" deduplication', () => {
-    const inventorySpool = {
-      id: 42,
-      material: 'PLA',
-      brand: 'eSun',
-      color_name: 'Black',
-    };
-
-    it('shows only one Open in Inventory button when spoolman linkedSpoolId matches assignedSpool id', async () => {
-      renderWithHover(
-        <FilamentHoverCard
-          data={baseFilamentData}
-          spoolman={{ enabled: true, linkedSpoolId: 42 }}
-          inventory={{ assignedSpool: inventorySpool }}
-        >
-          <div>trigger</div>
-        </FilamentHoverCard>
-      );
-      vi.advanceTimersByTime(100);
-      await waitFor(() => {
-        expect(screen.getByText(/assigned/i)).toBeInTheDocument();
-      });
-      expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
-    });
-
-    it('shows two Open in Inventory buttons when spoolman and inventory point to different spools', async () => {
-      renderWithHover(
-        <FilamentHoverCard
-          data={baseFilamentData}
-          spoolman={{ enabled: true, linkedSpoolId: 99 }}
-          inventory={{ assignedSpool: inventorySpool }}
-        >
-          <div>trigger</div>
-        </FilamentHoverCard>
-      );
-      vi.advanceTimersByTime(100);
-      await waitFor(() => {
-        expect(screen.getByText(/assigned/i)).toBeInTheDocument();
-      });
-      expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(2);
-    });
-
-    it('shows one Open in Inventory button when spoolman is absent but inventory spool is assigned', async () => {
-      renderWithHover(
-        <FilamentHoverCard
-          data={baseFilamentData}
-          inventory={{ assignedSpool: inventorySpool }}
-        >
-          <div>trigger</div>
-        </FilamentHoverCard>
-      );
-      vi.advanceTimersByTime(100);
-      await waitFor(() => {
-        expect(screen.getByText(/assigned/i)).toBeInTheDocument();
-      });
-      expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
-    });
   });
 });
 
@@ -325,7 +246,7 @@ describe('EmptySlotHoverCard (#1133)', () => {
     vi.useFakeTimers({ shouldAdvanceTime: true });
   });
 
-  it('does not render an assign-spool button when onAssignSpool is not provided', async () => {
+  it('does not render an assign-spool affordance', async () => {
     const result = render(
       <EmptySlotHoverCard configureSlot={{ enabled: true, onConfigure: vi.fn() }}>
         <div>trigger</div>
@@ -338,7 +259,7 @@ describe('EmptySlotHoverCard (#1133)', () => {
       // a card that simply never opened.
       expect(screen.getByText(/empty/i)).toBeInTheDocument();
     });
-    expect(screen.queryByText(/assign spool/i)).not.toBeInTheDocument();
+    expect(screen.queryByText(/assign/i)).not.toBeInTheDocument();
   });
 
   it('still shows the configure button on an empty slot', async () => {
@@ -354,32 +275,4 @@ describe('EmptySlotHoverCard (#1133)', () => {
       expect(screen.getByText(/configure/i)).toBeInTheDocument();
     });
   });
-
-  it('shows Assign Spool button when onAssignSpool is provided', async () => {
-    const onAssign = vi.fn();
-    const result = render(
-      <EmptySlotHoverCard onAssignSpool={onAssign}>
-        <div>trigger</div>
-      </EmptySlotHoverCard>
-    );
-    fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
-    vi.advanceTimersByTime(100);
-    await waitFor(() => {
-      expect(screen.getByText(/assign spool/i)).toBeInTheDocument();
-    });
-  });
-
-  it('calls onAssignSpool when Assign Spool button is clicked', async () => {
-    const onAssign = vi.fn();
-    const result = render(
-      <EmptySlotHoverCard onAssignSpool={onAssign}>
-        <div>trigger</div>
-      </EmptySlotHoverCard>
-    );
-    fireEvent.mouseEnter(result.container.firstElementChild as HTMLElement);
-    vi.advanceTimersByTime(100);
-    await waitFor(() => expect(screen.getByText(/assign spool/i)).toBeInTheDocument());
-    fireEvent.click(screen.getByText(/assign spool/i));
-    expect(onAssign).toHaveBeenCalledTimes(1);
-  });
 });

+ 0 - 122
frontend/src/__tests__/components/InventorySpoolInfoCard.test.tsx

@@ -1,122 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, fireEvent } from '@testing-library/react';
-import { render } from '../utils';
-import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
-import type { InventorySpool } from '../../api/client';
-
-vi.mock('../../api/client', () => ({
-  api: {
-    getSettings: vi.fn().mockResolvedValue({}),
-    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
-    getSpoolKProfiles: vi.fn().mockResolvedValue([]),
-  },
-  spoolbuddyApi: {
-    updateSpoolWeight: vi.fn().mockResolvedValue({ status: 'ok', weight_used: 300 }),
-  },
-}));
-
-const mockSpool: InventorySpool = {
-  id: 42,
-  material: 'PLA',
-  subtype: 'Matte',
-  color_name: 'Jade White',
-  rgba: 'E8F5E9FF',
-  extra_colors: null,
-  effect_type: null,
-  brand: 'Bambu',
-  label_weight: 1000,
-  core_weight: 250,
-  core_weight_catalog_id: null,
-  weight_used: 200,
-  slicer_filament: null,
-  slicer_filament_name: null,
-  nozzle_temp_min: null,
-  nozzle_temp_max: null,
-  note: null,
-  added_full: null,
-  last_used: null,
-  encode_time: null,
-  tag_uid: 'AABBCCDD11223344',
-  tray_uuid: null,
-  data_origin: 'local',
-  tag_type: null,
-  archived_at: null,
-  created_at: '2024-01-01T00:00:00Z',
-  updated_at: '2024-01-01T00:00:00Z',
-  cost_per_kg: null,
-  last_scale_weight: null,
-  last_weighed_at: null,
-  category: null,
-  low_stock_threshold_pct: null,
-  k_profiles: [],
-};
-
-describe('InventorySpoolInfoCard', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-  });
-
-  it('disables "Assign to AMS" button when isAssigned=true', () => {
-    render(
-      <InventorySpoolInfoCard
-        spool={mockSpool}
-        liveScaleWeight={null}
-        onAssignToAms={vi.fn()}
-        isAssigned
-      />
-    );
-    expect(screen.getByText('Assign to AMS')).toBeDisabled();
-  });
-
-  it('enables "Assign to AMS" button when isAssigned=false', () => {
-    render(
-      <InventorySpoolInfoCard
-        spool={mockSpool}
-        liveScaleWeight={null}
-        onAssignToAms={vi.fn()}
-        isAssigned={false}
-      />
-    );
-    expect(screen.getByText('Assign to AMS')).not.toBeDisabled();
-  });
-
-  it('shows Unassign button when isAssigned=true and onUnassignFromAms is provided', () => {
-    render(
-      <InventorySpoolInfoCard
-        spool={mockSpool}
-        liveScaleWeight={null}
-        onAssignToAms={vi.fn()}
-        isAssigned
-        onUnassignFromAms={vi.fn()}
-      />
-    );
-    expect(screen.getByText(/unassign/i)).toBeInTheDocument();
-  });
-
-  it('does not show Unassign button when onUnassignFromAms is not provided', () => {
-    render(
-      <InventorySpoolInfoCard
-        spool={mockSpool}
-        liveScaleWeight={null}
-        onAssignToAms={vi.fn()}
-        isAssigned
-      />
-    );
-    expect(screen.queryByText(/unassign/i)).not.toBeInTheDocument();
-  });
-
-  it('calls onUnassignFromAms when Unassign button is clicked', () => {
-    const onUnassign = vi.fn();
-    render(
-      <InventorySpoolInfoCard
-        spool={mockSpool}
-        liveScaleWeight={null}
-        onAssignToAms={vi.fn()}
-        isAssigned
-        onUnassignFromAms={onUnassign}
-      />
-    );
-    fireEvent.click(screen.getByText(/unassign/i));
-    expect(onUnassign).toHaveBeenCalledTimes(1);
-  });
-});

+ 0 - 473
frontend/src/__tests__/components/SpoolCatalogSettings.test.tsx

@@ -1,473 +0,0 @@
-import React from 'react';
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
-import { render } from '../utils';
-import { SpoolCatalogSettings } from '../../components/SpoolCatalogSettings';
-
-vi.mock('react-i18next', () => ({
-  useTranslation: () => ({
-    t: (key: string, fallback?: string) => fallback ?? key,
-  }),
-}));
-
-const mockShowToast = vi.fn();
-vi.mock('../../contexts/ToastContext', async (importOriginal) => {
-  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
-  return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
-});
-
-vi.mock('../../api/client', () => ({
-  api: {
-    getSettings: vi.fn().mockResolvedValue({}),
-    getSpoolCatalog: vi.fn().mockResolvedValue([]),
-    getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
-    patchSpoolmanFilament: vi.fn().mockResolvedValue({
-      id: 1,
-      name: 'PLA Basic',
-      material: 'PLA',
-      color_hex: 'FF0000',
-      color_name: 'Red',
-      weight: 1000,
-      spool_weight: 196,
-      vendor: { id: 1, name: 'Bambu Lab' },
-    }),
-  },
-  ApiError: class ApiError extends Error {
-    status: number;
-    constructor(message: string, status: number) {
-      super(message);
-      this.status = status;
-    }
-  },
-}));
-
-import { api, ApiError } from '../../api/client';
-
-const sampleFilament = {
-  id: 1,
-  name: 'PLA Basic',
-  material: 'PLA',
-  color_hex: 'FF0000',
-  color_name: 'Red',
-  weight: 1000,
-  spool_weight: 196,
-  vendor: { id: 1, name: 'Bambu Lab' },
-};
-
-describe('SpoolCatalogSettings — mode switching', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-    vi.mocked(api.getSpoolCatalog).mockResolvedValue([]);
-  });
-
-  it('hides Spoolman table and shows local CRUD buttons when Spoolman is disabled (400)', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
-      new ApiError('disabled', 400)
-    );
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      // Local mode: Add button visible
-      expect(screen.getByText('common.add')).toBeTruthy();
-    });
-
-    // Spoolman table columns must NOT appear
-    expect(screen.queryByText('settings.catalog.material')).toBeNull();
-    expect(screen.queryByText('settings.catalog.spoolWeight')).toBeNull();
-    // Spoolman catalog title must NOT appear
-    expect(screen.queryByText('settings.spoolmanFilamentCatalogTitle')).toBeNull();
-  });
-
-  it('shows Spoolman error row when Spoolman is unreachable (503)', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
-      new ApiError('unreachable', 503)
-    );
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText('inventory.spoolmanCatalogLoadFailed')).toBeTruthy();
-    });
-
-    // Local CRUD buttons must NOT appear in Spoolman mode
-    expect(screen.queryByText('common.add')).toBeNull();
-  });
-
-  it('shows empty state when Spoolman returns an empty list', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText('inventory.noSpoolmanFilaments')).toBeTruthy();
-    });
-
-    // Local CRUD buttons must NOT appear
-    expect(screen.queryByText('common.add')).toBeNull();
-  });
-
-  it('renders Spoolman filament rows with vendor and name combined', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-  });
-
-  it('(local mode) shows Export, Import, Reset, Add buttons when Spoolman disabled', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
-      new ApiError('disabled', 400)
-    );
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText('common.add')).toBeTruthy();
-    });
-
-    expect(screen.getByText('common.export')).toBeTruthy();
-    expect(screen.getByText('common.import')).toBeTruthy();
-    expect(screen.getByText('common.reset')).toBeTruthy();
-  });
-
-  it('(spoolman mode) hides Export, Import, Reset, Add buttons when Spoolman is enabled', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    expect(screen.queryByText('common.add')).toBeNull();
-    expect(screen.queryByText('common.export')).toBeNull();
-    expect(screen.queryByText('common.import')).toBeNull();
-    expect(screen.queryByText('common.reset')).toBeNull();
-  });
-
-  it('(spoolman mode) renders correct column headers — Name, Material, Weight, Spool Weight', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText('common.name')).toBeTruthy();
-    });
-
-    expect(screen.getByText('settings.catalog.material')).toBeTruthy();
-    expect(screen.getByText('settings.catalog.weight')).toBeTruthy();
-    expect(screen.getByText('settings.catalog.spoolWeight')).toBeTruthy();
-  });
-
-  it('(spoolman mode) renders all data fields for a filament row', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    // Material column
-    expect(screen.getByText('PLA')).toBeTruthy();
-    // Filament weight
-    expect(screen.getByText('1000g')).toBeTruthy();
-    // Spool (empty) weight
-    expect(screen.getByText('196g')).toBeTruthy();
-  });
-
-  it('(spoolman mode) renders color swatch with correct background color', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
-      { ...sampleFilament, color_hex: 'FF5500' },
-    ]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
-    const bg = (swatch as HTMLElement).style.backgroundColor;
-    // Accepts both hex-like and rgb() representations
-    expect(bg).toBeTruthy();
-    expect(bg).not.toBe('');
-  });
-
-  it('(spoolman mode) renders fallback color when color_hex is null', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
-      { ...sampleFilament, color_hex: null },
-    ]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
-    expect((swatch as HTMLElement).style.backgroundColor).toContain('128');
-  });
-
-  it('(spoolman mode) renders dash for null material, weight, and spool_weight', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
-      { ...sampleFilament, material: null, weight: null, spool_weight: null },
-    ]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    // All three nullable fields must show '—', not 'nullg' or empty string
-    const dashes = screen.getAllByText('—');
-    expect(dashes.length).toBeGreaterThanOrEqual(3);
-  });
-
-  it('(spoolman mode) shows Spoolman catalog title, not local catalog title', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText('settings.spoolmanFilamentCatalogTitle')).toBeTruthy();
-    });
-
-    expect(screen.queryByText('settings.catalog.spoolCatalog')).toBeNull();
-  });
-
-  it('(spoolman mode) shows pencil edit button in each filament row', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    const editButtons = screen.getAllByLabelText('common.edit');
-    expect(editButtons.length).toBeGreaterThanOrEqual(1);
-  });
-
-  it('(spoolman mode) clicking pencil shows name and weight inputs', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.name')).toBeTruthy();
-      expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
-    });
-  });
-
-  it('(spoolman mode) name input is pre-filled with current name', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      const nameInput = screen.getByLabelText('common.name') as HTMLInputElement;
-      expect(nameInput.value).toBe('PLA Basic');
-    });
-  });
-
-  it('(spoolman mode) weight input is pre-filled with current spool_weight', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      const weightInput = screen.getByLabelText('settings.catalog.spoolWeight') as HTMLInputElement;
-      expect(weightInput.value).toBe('196');
-    });
-  });
-
-  it('(spoolman mode) cancel edit restores read-only display', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.cancel')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.cancel'));
-
-    await waitFor(() => {
-      expect(screen.queryByLabelText('common.name')).toBeNull();
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-  });
-
-  it('(spoolman mode) empty name input disables save button', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.name')).toBeTruthy();
-    });
-
-    const nameInput = screen.getByLabelText('common.name');
-    fireEvent.change(nameInput, { target: { value: '' } });
-
-    const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
-    expect(saveBtn.disabled).toBe(true);
-  });
-
-  it('(spoolman mode) saving name-only calls patchSpoolmanFilament without modal', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.name')).toBeTruthy();
-    });
-
-    const nameInput = screen.getByLabelText('common.name');
-    fireEvent.change(nameInput, { target: { value: 'PLA Basic Renamed' } });
-
-    fireEvent.click(screen.getByLabelText('common.save'));
-
-    await waitFor(() => {
-      expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(1, { name: 'PLA Basic Renamed' });
-    });
-
-    // Modal must NOT appear
-    expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
-  });
-
-  it('(spoolman mode) saving changed spool_weight opens SpoolWeightUpdateModal', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
-    });
-
-    const weightInput = screen.getByLabelText('settings.catalog.spoolWeight');
-    fireEvent.change(weightInput, { target: { value: '100' } });
-
-    fireEvent.click(screen.getByLabelText('common.save'));
-
-    await waitFor(() => {
-      expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
-    });
-  });
-
-  it('(spoolman mode) confirming option B calls patchSpoolmanFilament with keep_existing_spools=false', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
-    fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
-    fireEvent.click(screen.getByLabelText('common.save'));
-
-    await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
-
-    // Confirm with option B selected by default
-    fireEvent.click(screen.getByText('common.confirm'));
-
-    await waitFor(() => {
-      expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
-        1,
-        expect.objectContaining({ spool_weight: 100, keep_existing_spools: false }),
-      );
-    });
-  });
-
-  it('(spoolman mode) confirming option A calls patchSpoolmanFilament with keep_existing_spools=true', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
-    fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
-    fireEvent.click(screen.getByLabelText('common.save'));
-
-    await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
-
-    // Select option A (keep existing)
-    const radios = screen.getAllByRole('radio');
-    fireEvent.click(radios[1]); // Option A = second radio = keepExisting=true
-
-    fireEvent.click(screen.getByText('common.confirm'));
-
-    await waitFor(() => {
-      expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
-        1,
-        expect.objectContaining({ spool_weight: 100, keep_existing_spools: true }),
-      );
-    });
-  });
-
-  it('(spoolman mode) negative weight input disables save button', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
-
-    fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '-5' } });
-
-    const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
-    expect(saveBtn.disabled).toBe(true);
-  });
-});

+ 0 - 460
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -26,24 +26,8 @@ vi.mock('../../api/client', () => ({
     getPrinters: vi.fn().mockResolvedValue([]),
     getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
     createSpool: vi.fn().mockResolvedValue({ id: 99 }),
-    createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 88 }),
     updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
-    saveSpoolmanKProfiles: vi.fn().mockResolvedValue([]),
-    updateSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 42 }),
-    bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
-      created: [{ id: 1, material: 'PLA' }],
-      requested_count: 1,
-      failed_count: 0,
-    }),
-    getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
-  },
-  ApiError: class ApiError extends Error {
-    status: number;
-    constructor(message: string, status: number) {
-      super(message);
-      this.status = status;
-    }
   },
 }));
 
@@ -433,108 +417,6 @@ describe('SpoolFormModal weightTouched', () => {
     expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
   });
 
-  it('shows warning toast on partial bulk-create in Spoolman mode (T1/partial)', async () => {
-    vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
-      created: [{ id: 1, material: 'PLA' } as InventorySpool],
-      requested_count: 3,
-      failed_count: 2,
-    });
-
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        currencySymbol="$"
-        spoolmanMode={true}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
-    });
-
-    // Enable Quick Add mode so the quantity field appears
-    const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
-    const toggleButton = quickAddRow?.querySelector('button[type="button"]');
-    expect(toggleButton).toBeTruthy();
-    fireEvent.click(toggleButton!);
-
-    // Set quantity to 3 (triggers bulkCreateMutation instead of createMutation)
-    const quantityContainer = screen.getByText('Quantity').closest('div');
-    const quantityInput = quantityContainer?.querySelector('input[type="number"]');
-    expect(quantityInput).toBeTruthy();
-    fireEvent.change(quantityInput!, { target: { value: '3' } });
-
-    // Click the submit button
-    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
-    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
-    expect(submitButton).toBeTruthy();
-    fireEvent.click(submitButton!);
-
-    await waitFor(() => {
-      expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
-    });
-
-    // Should show a warning toast for partial failure (1 created, 2 failed, 3 requested)
-    expect(mockShowToast).toHaveBeenCalledWith(
-      expect.stringContaining('1 of 3'),
-      'warning',
-    );
-  });
-
-  it('shows success toast on full bulk-create success in Spoolman mode (T1/success)', async () => {
-    vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({
-      created: [
-        { id: 1, material: 'PLA' } as InventorySpool,
-        { id: 2, material: 'PLA' } as InventorySpool,
-        { id: 3, material: 'PLA' } as InventorySpool,
-      ],
-      requested_count: 3,
-      failed_count: 0,
-    });
-
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        currencySymbol="$"
-        spoolmanMode={true}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
-    });
-
-    // Enable Quick Add mode so the quantity field appears
-    const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]');
-    const toggleButton = quickAddRow?.querySelector('button[type="button"]');
-    expect(toggleButton).toBeTruthy();
-    fireEvent.click(toggleButton!);
-
-    // Set quantity to 3
-    const quantityContainer = screen.getByText('Quantity').closest('div');
-    const quantityInput = quantityContainer?.querySelector('input[type="number"]');
-    expect(quantityInput).toBeTruthy();
-    fireEvent.change(quantityInput!, { target: { value: '3' } });
-
-    // Click the submit button
-    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
-    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
-    expect(submitButton).toBeTruthy();
-    fireEvent.click(submitButton!);
-
-    await waitFor(() => {
-      expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1);
-    });
-
-    // Should show a success toast listing the count of created spools
-    expect(mockShowToast).toHaveBeenCalledWith(
-      expect.stringContaining('3'),
-      'success',
-    );
-  });
-
   it('displays correct catalog name when duplicates exist', async () => {
     const spoolWithCatalogId: InventorySpool = {
       ...existingSpool,
@@ -578,345 +460,3 @@ describe('SpoolFormModal weightTouched', () => {
     });
   });
 });
-
-describe('SpoolFormModal Spoolman K-profile support', () => {
-  const spoolmanSpool: InventorySpool = {
-    ...{
-      id: 42,
-      material: 'PLA',
-      subtype: 'Basic',
-      brand: 'BrandX',
-      color_name: 'Black',
-      rgba: '000000FF',
-      label_weight: 1000,
-      core_weight: 250,
-      core_weight_catalog_id: null,
-      weight_used: 200,
-      slicer_filament: '',
-      slicer_filament_name: '',
-      nozzle_temp_min: null,
-      nozzle_temp_max: null,
-      note: null,
-      added_full: null,
-      last_used: null,
-      encode_time: null,
-      tag_uid: null,
-      tray_uuid: null,
-      data_origin: 'spoolman',
-      tag_type: 'spoolman',
-      archived_at: null,
-      created_at: '2025-01-01T00:00:00Z',
-      updated_at: '2025-01-01T00:00:00Z',
-      k_profiles: [],
-    },
-  } as InventorySpool;
-
-  beforeEach(() => {
-    vi.clearAllMocks();
-  });
-
-  it('shows PA Profile tab for Spoolman spools in non-quickAdd mode', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        spool={spoolmanSpool}
-        currencySymbol="$"
-        spoolmanMode={true}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
-    });
-
-    // PA Profile tab should be visible in Spoolman mode
-    expect(screen.getByText('PA Profile')).toBeInTheDocument();
-  });
-
-  it('calls saveSpoolmanKProfiles (not saveSpoolKProfiles) on update in Spoolman mode', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        spool={spoolmanSpool}
-        currencySymbol="$"
-        spoolmanMode={true}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
-    });
-
-    const saveButton = screen.getByRole('button', { name: /save/i });
-    fireEvent.click(saveButton);
-
-    await waitFor(() => {
-      expect(api.updateSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
-    });
-
-    // saveSpoolmanKProfiles is always called on update (even with empty list)
-    await waitFor(() => {
-      expect(api.saveSpoolmanKProfiles).toHaveBeenCalledWith(42, []);
-    });
-    expect(api.saveSpoolKProfiles).not.toHaveBeenCalled();
-  });
-});
-
-// ---------------------------------------------------------------------------
-// T2: SpoolmanFilamentPicker integration with SpoolFormModal
-// ---------------------------------------------------------------------------
-
-vi.mock('../../components/spool-form/SpoolmanFilamentPicker', () => ({
-  SpoolmanFilamentPicker: ({ onSelect, selectedId }: { onSelect: (f: unknown) => void; selectedId: number | null; isLoading: boolean; filaments: unknown[] }) => {
-    return (
-      <div>
-        <span data-testid="picker-selected-id">{selectedId ?? 'none'}</span>
-        <button data-testid="picker-select-btn" onClick={() => onSelect({
-          id: 7,
-          name: 'PLA Basic',
-          material: 'PLA',
-          color_hex: 'FF0000',
-          color_name: 'Red',
-          weight: 1000,
-          spool_weight: 196,
-          vendor: { id: 1, name: 'Bambu Lab' },
-        })}>
-          Select Filament
-        </button>
-      </div>
-    );
-  },
-}));
-
-describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-  });
-
-  it('renders SpoolmanFilamentPicker in Spoolman create mode', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        currencySymbol="$"
-        spoolmanMode={true}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
-    });
-  });
-
-  it('does NOT render SpoolmanFilamentPicker in local inventory mode', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        currencySymbol="$"
-        spoolmanMode={false}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.queryByTestId('picker-select-btn')).not.toBeInTheDocument();
-    });
-  });
-
-  it('prefills form fields when a filament is selected from the picker', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        currencySymbol="$"
-        spoolmanMode={true}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
-    });
-
-    fireEvent.click(screen.getByTestId('picker-select-btn'));
-
-    // After selection, the picker should reflect the selected ID
-    await waitFor(() => {
-      expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
-    });
-  });
-
-  it('includes spoolman_filament_id in the submit payload when a filament is pre-selected', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        currencySymbol="$"
-        spoolmanMode={true}
-        spoolsQueryKey={['spoolman-spools']}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
-    });
-
-    // Select a filament
-    fireEvent.click(screen.getByTestId('picker-select-btn'));
-
-    // Submit the form
-    const saveButton = screen.getByRole('button', { name: /save|add spool/i });
-    fireEvent.click(saveButton);
-
-    await waitFor(() => {
-      expect(api.createSpoolmanInventorySpool).toHaveBeenCalledTimes(1);
-    });
-
-    const callArg = vi.mocked(api.createSpoolmanInventorySpool).mock.calls[0][0] as Record<string, unknown>;
-    expect(callArg.spoolman_filament_id).toBe(7);
-  });
-
-  it('clears spoolman_filament_id and shows unlink toast when user edits a linked field', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        currencySymbol="$"
-        spoolmanMode={true}
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByTestId('picker-select-btn')).toBeInTheDocument();
-    });
-
-    // Select a filament from the catalog picker
-    fireEvent.click(screen.getByTestId('picker-select-btn'));
-
-    await waitFor(() => {
-      expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
-    });
-
-    // Manually edit the color_name field (a linked field)
-    const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...');
-    fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } });
-
-    // spoolman_filament_id must be cleared (picker shows 'none')
-    await waitFor(() => {
-      expect(screen.getByTestId('picker-selected-id').textContent).toBe('none');
-    });
-
-    // Unlink toast must have been shown
-    expect(mockShowToast).toHaveBeenCalledWith(
-      expect.stringContaining('catalog link'),
-      'info',
-    );
-  });
-});
-
-describe('SpoolFormModal storageLocationTouched', () => {
-  /**
-   * Regression tests for the round-trip bug: saving the edit modal without
-   * touching the Storage Location field must NOT include storage_location in
-   * the PATCH payload, so Spoolman's location field is never overwritten with
-   * a stale cached value.
-   */
-  beforeEach(() => {
-    vi.clearAllMocks();
-  });
-
-  const spoolWithStorageLocation: InventorySpool = {
-    ...existingSpool,
-    storage_location: 'IKEAREGAL',
-  };
-
-  it('excludes storage_location from PATCH when editing without changing it', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        spool={spoolWithStorageLocation}
-        currencySymbol="$"
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
-    });
-
-    // Save without touching the storage location field
-    const saveButton = screen.getByRole('button', { name: /save/i });
-    fireEvent.click(saveButton);
-
-    await waitFor(() => {
-      expect(api.updateSpool).toHaveBeenCalledTimes(1);
-    });
-
-    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
-    expect(spoolId).toBe(1);
-    // storage_location must NOT be in the payload — prevents Spoolman location overwrite
-    expect(payload).not.toHaveProperty('storage_location');
-    // Other fields should still be present
-    expect(payload).toHaveProperty('material', 'PLA');
-  });
-
-  it('includes storage_location in PATCH when editing and changing it', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        spool={spoolWithStorageLocation}
-        currencySymbol="$"
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
-    });
-
-    // Find the storage location input and change it
-    const locationInput = screen.getByPlaceholderText('e.g. Shelf A, Drawer 1');
-    fireEvent.change(locationInput, { target: { value: 'Shelf B' } });
-
-    const saveButton = screen.getByRole('button', { name: /save/i });
-    fireEvent.click(saveButton);
-
-    await waitFor(() => {
-      expect(api.updateSpool).toHaveBeenCalledTimes(1);
-    });
-
-    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
-    expect(spoolId).toBe(1);
-    // storage_location MUST be present since the user changed it
-    expect(payload).toHaveProperty('storage_location', 'Shelf B');
-  });
-
-  it('includes storage_location when creating a new spool', async () => {
-    render(
-      <SpoolFormModal
-        isOpen={true}
-        onClose={vi.fn()}
-        currencySymbol="$"
-      />
-    );
-
-    await waitFor(() => {
-      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
-    });
-
-    // Submit without setting storage_location (validation is mocked to pass)
-    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
-    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
-    expect(submitButton).toBeTruthy();
-    fireEvent.click(submitButton!);
-
-    await waitFor(() => {
-      expect(api.createSpool).toHaveBeenCalledTimes(1);
-    });
-
-    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
-    // storage_location MUST be included for new spools (default empty string → null)
-    expect(payload).toHaveProperty('storage_location', null);
-  });
-});

+ 0 - 49
frontend/src/__tests__/components/SpoolInfoCard.test.tsx

@@ -92,55 +92,6 @@ describe('SpoolInfoCard', () => {
     fireEvent.click(screen.getByText('Close'));
     expect(onClose).toHaveBeenCalledTimes(1);
   });
-
-  it('disables "Assign to AMS" button when isAssigned=true', () => {
-    render(
-      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={vi.fn()} isAssigned />
-    );
-    expect(screen.getByText('Assign to AMS')).toBeDisabled();
-  });
-
-  it('enables "Assign to AMS" button when isAssigned=false', () => {
-    render(
-      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={vi.fn()} isAssigned={false} />
-    );
-    expect(screen.getByText('Assign to AMS')).not.toBeDisabled();
-  });
-
-  it('shows Unassign button when isAssigned=true and onUnassignFromAms is provided', () => {
-    render(
-      <SpoolInfoCard
-        spool={mockSpool}
-        scaleWeight={800}
-        onAssignToAms={vi.fn()}
-        isAssigned
-        onUnassignFromAms={vi.fn()}
-      />
-    );
-    expect(screen.getByText(/unassign/i)).toBeInTheDocument();
-  });
-
-  it('does not show Unassign button when onUnassignFromAms is not provided', () => {
-    render(
-      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={vi.fn()} isAssigned />
-    );
-    expect(screen.queryByText(/unassign/i)).not.toBeInTheDocument();
-  });
-
-  it('calls onUnassignFromAms when Unassign button is clicked', () => {
-    const onUnassign = vi.fn();
-    render(
-      <SpoolInfoCard
-        spool={mockSpool}
-        scaleWeight={800}
-        onAssignToAms={vi.fn()}
-        isAssigned
-        onUnassignFromAms={onUnassign}
-      />
-    );
-    fireEvent.click(screen.getByText(/unassign/i));
-    expect(onUnassign).toHaveBeenCalledTimes(1);
-  });
 });
 
 describe('UnknownTagCard', () => {

+ 0 - 97
frontend/src/__tests__/components/SpoolWeightUpdateModal.test.tsx

@@ -1,97 +0,0 @@
-import React from 'react';
-import { describe, it, expect, vi } from 'vitest';
-import { screen, fireEvent } from '@testing-library/react';
-import { render } from '../utils';
-import { SpoolWeightUpdateModal } from '../../components/SpoolWeightUpdateModal';
-
-vi.mock('react-i18next', () => ({
-  useTranslation: () => ({
-    t: (key: string) => key,
-  }),
-}));
-
-const defaultProps = {
-  isOpen: true,
-  filamentName: 'PLA Basic',
-  oldWeight: 250,
-  newWeight: 196,
-  onConfirm: vi.fn(),
-  onClose: vi.fn(),
-};
-
-describe('SpoolWeightUpdateModal', () => {
-  it('renders filament name, old weight, and new weight', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} />);
-
-    expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
-    expect(screen.getByText(/PLA Basic/)).toBeTruthy();
-    expect(screen.getByText(/250g → 196g/)).toBeTruthy();
-  });
-
-  it('renders option labels', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} />);
-
-    expect(screen.getByText('settings.catalog.applyToAllSpools')).toBeTruthy();
-    expect(screen.getByText('settings.catalog.keepExistingSpoolWeight')).toBeTruthy();
-  });
-
-  it('option B (apply to all) is selected by default', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} />);
-
-    const radios = screen.getAllByRole('radio') as HTMLInputElement[];
-    // First radio = apply-to-all (Option B, keepExisting=false)
-    expect(radios[0].checked).toBe(true);
-    expect(radios[1].checked).toBe(false);
-  });
-
-  it('calls onConfirm(false) when option B is selected and Confirm clicked', () => {
-    const onConfirm = vi.fn();
-    render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
-
-    fireEvent.click(screen.getByText('common.confirm'));
-
-    expect(onConfirm).toHaveBeenCalledWith(false);
-  });
-
-  it('calls onConfirm(true) when option A is selected and Confirm clicked', () => {
-    const onConfirm = vi.fn();
-    render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
-
-    const radios = screen.getAllByRole('radio');
-    fireEvent.click(radios[1]); // Option A: keep existing
-
-    fireEvent.click(screen.getByText('common.confirm'));
-
-    expect(onConfirm).toHaveBeenCalledWith(true);
-  });
-
-  it('calls onClose on Cancel click', () => {
-    const onClose = vi.fn();
-    render(<SpoolWeightUpdateModal {...defaultProps} onClose={onClose} />);
-
-    fireEvent.click(screen.getByText('common.cancel'));
-
-    expect(onClose).toHaveBeenCalled();
-  });
-
-  it('does not call onConfirm on Cancel click', () => {
-    const onConfirm = vi.fn();
-    render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
-
-    fireEvent.click(screen.getByText('common.cancel'));
-
-    expect(onConfirm).not.toHaveBeenCalled();
-  });
-
-  it('renders dash when oldWeight is null', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} oldWeight={null} />);
-
-    expect(screen.getByText(/— → 196g/)).toBeTruthy();
-  });
-
-  it('returns null when isOpen is false', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} isOpen={false} />);
-
-    expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
-  });
-});

+ 0 - 188
frontend/src/__tests__/components/SpoolmanFilamentPicker.test.tsx

@@ -1,188 +0,0 @@
-import React from 'react';
-import { describe, it, expect, vi } from 'vitest';
-import { screen, fireEvent } from '@testing-library/react';
-import { render } from '../utils';
-import { SpoolmanFilamentPicker } from '../../components/spool-form/SpoolmanFilamentPicker';
-import type { SpoolmanFilamentEntry } from '../../api/client';
-
-vi.mock('react-i18next', () => ({
-  useTranslation: () => ({
-    t: (key: string, fallback?: string) => fallback ?? key,
-  }),
-}));
-
-const FILAMENTS: SpoolmanFilamentEntry[] = [
-  {
-    id: 1,
-    name: 'PLA Basic',
-    material: 'PLA',
-    color_hex: 'FF0000',
-    color_name: 'Red',
-    weight: 1000,
-    spool_weight: 196,
-    vendor: { id: 1, name: 'Bambu Lab' },
-  },
-  {
-    id: 2,
-    name: 'PETG',
-    material: 'PETG',
-    color_hex: '00FF00',
-    color_name: 'Green',
-    weight: 1000,
-    spool_weight: null,
-    vendor: { id: 2, name: 'Bambu Lab' },
-  },
-  {
-    id: 3,
-    name: 'ABS Basic',
-    material: 'ABS',
-    color_hex: null,
-    color_name: null,
-    weight: 1000,
-    spool_weight: 250,
-    vendor: null,
-  },
-];
-
-function openDropdown() {
-  const trigger = screen.getByRole('button', { name: /spoolman/i });
-  fireEvent.click(trigger);
-}
-
-describe('SpoolmanFilamentPicker', () => {
-  it('renders the trigger button with catalog label when nothing selected', () => {
-    render(
-      <SpoolmanFilamentPicker
-        filaments={FILAMENTS}
-        isLoading={false}
-        selectedId={null}
-        onSelect={vi.fn()}
-      />
-    );
-    expect(screen.getByText('inventory.spoolmanFilamentCatalog')).toBeTruthy();
-  });
-
-  it('shows all filaments when dropdown is opened', () => {
-    render(
-      <SpoolmanFilamentPicker
-        filaments={FILAMENTS}
-        isLoading={false}
-        selectedId={null}
-        onSelect={vi.fn()}
-      />
-    );
-    openDropdown();
-    expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    expect(screen.getByText(/Bambu Lab — PETG/)).toBeTruthy();
-    expect(screen.getByText(/ABS Basic/)).toBeTruthy();
-  });
-
-  it('filters by search term (material)', () => {
-    render(
-      <SpoolmanFilamentPicker
-        filaments={FILAMENTS}
-        isLoading={false}
-        selectedId={null}
-        onSelect={vi.fn()}
-      />
-    );
-    openDropdown();
-    const searchInput = screen.getByPlaceholderText('inventory.pickFromSpoolmanCatalog');
-    fireEvent.change(searchInput, { target: { value: 'PETG' } });
-    expect(screen.getAllByText(/PETG/).length).toBeGreaterThan(0);
-    expect(screen.queryByText(/PLA Basic/)).toBeNull();
-    expect(screen.queryByText(/ABS Basic/)).toBeNull();
-  });
-
-  it('filters by vendor name', () => {
-    render(
-      <SpoolmanFilamentPicker
-        filaments={FILAMENTS}
-        isLoading={false}
-        selectedId={null}
-        onSelect={vi.fn()}
-      />
-    );
-    openDropdown();
-    const searchInput = screen.getByPlaceholderText('inventory.pickFromSpoolmanCatalog');
-    fireEvent.change(searchInput, { target: { value: 'Bambu' } });
-    // ABS Basic has no vendor — should be filtered out
-    expect(screen.queryByText(/ABS Basic/)).toBeNull();
-  });
-
-  it('calls onSelect with the correct filament when an item is clicked', () => {
-    const onSelect = vi.fn();
-    render(
-      <SpoolmanFilamentPicker
-        filaments={FILAMENTS}
-        isLoading={false}
-        selectedId={null}
-        onSelect={onSelect}
-      />
-    );
-    openDropdown();
-    fireEvent.click(screen.getByText(/Bambu Lab — PLA Basic/));
-    expect(onSelect).toHaveBeenCalledOnce();
-    expect(onSelect).toHaveBeenCalledWith(FILAMENTS[0]);
-  });
-
-  it('shows empty state when no filaments match search', () => {
-    render(
-      <SpoolmanFilamentPicker
-        filaments={FILAMENTS}
-        isLoading={false}
-        selectedId={null}
-        onSelect={vi.fn()}
-      />
-    );
-    openDropdown();
-    const searchInput = screen.getByPlaceholderText('inventory.pickFromSpoolmanCatalog');
-    fireEvent.change(searchInput, { target: { value: 'xyzzy-not-found' } });
-    expect(screen.getByText('inventory.noSpoolmanFilaments')).toBeTruthy();
-  });
-
-  it('shows loading spinner when isLoading is true', () => {
-    render(
-      <SpoolmanFilamentPicker
-        filaments={[]}
-        isLoading={true}
-        selectedId={null}
-        onSelect={vi.fn()}
-      />
-    );
-    // Trigger button shows spinner (Loader2 icon with animate-spin)
-    const spinners = document.querySelectorAll('.animate-spin');
-    expect(spinners.length).toBeGreaterThan(0);
-  });
-
-  it('shows selected filament name in trigger button', () => {
-    render(
-      <SpoolmanFilamentPicker
-        filaments={FILAMENTS}
-        isLoading={false}
-        selectedId={1}
-        onSelect={vi.fn()}
-      />
-    );
-    // Should display the selected filament in the trigger
-    expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-  });
-
-  it('filters by color_name', () => {
-    render(
-      <SpoolmanFilamentPicker
-        filaments={FILAMENTS}
-        isLoading={false}
-        selectedId={null}
-        onSelect={vi.fn()}
-      />
-    );
-    openDropdown();
-    const searchInput = screen.getByPlaceholderText('inventory.pickFromSpoolmanCatalog');
-    fireEvent.change(searchInput, { target: { value: 'Green' } });
-    // PETG has color_name 'Green' — should match
-    expect(screen.getAllByText(/PETG/).length).toBeGreaterThan(0);
-    // PLA Basic has color_name 'Red' — should be filtered out
-    expect(screen.queryByText(/PLA Basic/)).toBeNull();
-  });
-});

+ 0 - 48
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -26,7 +26,6 @@ vi.mock('../../api/client', () => ({
     disconnectSpoolman: vi.fn(),
     syncAllPrintersAms: vi.fn(),
     syncPrinterAms: vi.fn(),
-    syncSpoolmanAmsWeights: vi.fn(),
     getPrinters: vi.fn(),
     getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
   },
@@ -320,51 +319,4 @@ describe('SpoolmanSettings', () => {
       });
     });
   });
-
-  describe('Spoolman AMS weight sync button', () => {
-    beforeEach(() => {
-      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
-        spoolman_enabled: 'true',
-        spoolman_url: 'http://localhost:7912',
-        spoolman_sync_mode: 'auto',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'true',
-      });
-      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
-        enabled: true,
-        connected: true,
-        url: 'http://localhost:7912',
-      });
-      vi.mocked(api.syncSpoolmanAmsWeights).mockResolvedValue({ synced: 2, skipped: 1 });
-    });
-
-    it('shows Spoolman AMS sync button when connected', async () => {
-      render(<SpoolmanSettings />);
-
-      // Text appears in both the label <p> and the button — use getAllByText
-      await waitFor(() => {
-        const elements = screen.getAllByText('Sync Spoolman Weights from AMS');
-        expect(elements.length).toBeGreaterThanOrEqual(1);
-      });
-    });
-
-    it('opens confirm modal when Spoolman AMS sync button clicked', async () => {
-      const user = userEvent.setup();
-      render(<SpoolmanSettings />);
-
-      // Wait for the button section to appear
-      await waitFor(() => {
-        const elements = screen.getAllByText('Sync Spoolman Weights from AMS');
-        expect(elements.length).toBeGreaterThanOrEqual(1);
-      });
-
-      // Find the button by role (not the label <p>) and click it
-      const syncButtons = screen.getAllByRole('button', { name: /Sync Spoolman Weights from AMS/i });
-      await user.click(syncButtons[0]);
-
-      await waitFor(() => {
-        expect(screen.getByText('Sync Spoolman Spool Weights from AMS')).toBeInTheDocument();
-      });
-    });
-  });
 });

+ 0 - 81
frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts

@@ -144,87 +144,6 @@ describe('useSpoolBuddyState', () => {
     expect(result.current.matchedSpool).toBeNull();
   });
 
-  it('UNKNOWN_TAG with tray_uuid sets unknownTrayUuid', () => {
-    const { result } = renderHook(() => useSpoolBuddyState());
-
-    act(() => {
-      dispatchCustomEvent('spoolbuddy-unknown-tag', {
-        tag_uid: 'AABB1122334455FF',
-        tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
-        device_id: 'dev-1',
-      });
-    });
-
-    expect(result.current.unknownTagUid).toBe('AABB1122334455FF');
-    expect(result.current.unknownTrayUuid).toBe('DEADBEEFDEADBEEFDEADBEEFDEADBEEF');
-  });
-
-  it('UNKNOWN_TAG without tray_uuid leaves unknownTrayUuid null', () => {
-    const { result } = renderHook(() => useSpoolBuddyState());
-
-    act(() => {
-      dispatchCustomEvent('spoolbuddy-unknown-tag', {
-        tag_uid: 'AABB1122',
-        device_id: 'dev-1',
-      });
-    });
-
-    expect(result.current.unknownTagUid).toBe('AABB1122');
-    expect(result.current.unknownTrayUuid).toBeNull();
-  });
-
-  it('TAG_MATCHED clears unknownTrayUuid', () => {
-    const { result } = renderHook(() => useSpoolBuddyState());
-
-    // Set tray_uuid via unknown tag
-    act(() => {
-      dispatchCustomEvent('spoolbuddy-unknown-tag', {
-        tag_uid: 'AABB1122334455FF',
-        tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
-        device_id: 'dev-1',
-      });
-    });
-    expect(result.current.unknownTrayUuid).toBe('DEADBEEFDEADBEEFDEADBEEFDEADBEEF');
-
-    // Match resolves it
-    act(() => {
-      dispatchCustomEvent('spoolbuddy-tag-matched', {
-        tag_uid: 'AABB1122334455FF',
-        device_id: 'dev-1',
-        spool: {
-          id: 5,
-          material: 'PLA',
-          label_weight: 1000,
-          core_weight: 250,
-          weight_used: 0,
-        },
-      });
-    });
-
-    expect(result.current.unknownTrayUuid).toBeNull();
-    expect(result.current.matchedSpool).not.toBeNull();
-  });
-
-  it('TAG_REMOVED clears unknownTrayUuid', () => {
-    const { result } = renderHook(() => useSpoolBuddyState());
-
-    act(() => {
-      dispatchCustomEvent('spoolbuddy-unknown-tag', {
-        tag_uid: 'AABB1122334455FF',
-        tray_uuid: 'CAFEBABECAFEBABECAFEBABECAFEBABE',
-        device_id: 'dev-1',
-      });
-    });
-    expect(result.current.unknownTrayUuid).toBe('CAFEBABECAFEBABECAFEBABECAFEBABE');
-
-    act(() => {
-      dispatchCustomEvent('spoolbuddy-tag-removed', { device_id: 'dev-1' });
-    });
-
-    expect(result.current.unknownTrayUuid).toBeNull();
-    expect(result.current.unknownTagUid).toBeNull();
-  });
-
   it('TAG_REMOVED clears both matchedSpool and unknownTagUid', () => {
     const { result } = renderHook(() => useSpoolBuddyState());
 

+ 0 - 268
frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx

@@ -1,268 +0,0 @@
-/**
- * Tests for the ?spool= deep-link flow in InventoryPage.
- *
- * Three scenarios:
- *  1. Spool is already in the loaded list → modal opens immediately.
- *  2. Spool is not in list → targeted API fetch succeeds → modal opens.
- *  3. Spool is not found (404) → error toast shown, param removed from URL.
- */
-
-import { describe, it, expect, beforeEach, afterEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
-import { render } from '../utils';
-import InventoryPageRouter from '../../pages/InventoryPage';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-// Minimal spool fixture shared across scenarios
-const BASE_SPOOL = {
-  id: 42,
-  material: 'PLA',
-  subtype: 'Basic',
-  brand: 'Bambu Lab',
-  color_name: 'Red',
-  rgba: 'FF0000FF',
-  label_weight: 1000,
-  core_weight: 250,
-  weight_used: 100,
-  slicer_filament: null,
-  slicer_filament_name: null,
-  nozzle_temp_min: 220,
-  nozzle_temp_max: 240,
-  note: null,
-  added_full: null,
-  last_used: null,
-  encode_time: null,
-  tag_uid: null,
-  tray_uuid: null,
-  data_origin: null,
-  tag_type: null,
-  archived_at: null,
-  created_at: '2025-01-01T00:00:00Z',
-  updated_at: '2025-01-01T00:00:00Z',
-  k_profiles: [],
-  cost_per_kg: null,
-  last_scale_weight: null,
-  last_weighed_at: null,
-  storage_location: null,
-  weight_locked: false,
-};
-
-const MOCK_SETTINGS = {
-  auto_archive: false,
-  save_thumbnails: false,
-  capture_finish_photo: false,
-  default_filament_cost: 25.0,
-  currency: 'USD',
-  energy_cost_per_kwh: 0.15,
-  energy_tracking_mode: 'total',
-  spoolman_enabled: false,
-  spoolman_url: '',
-  spoolman_sync_mode: 'auto',
-  spoolman_disable_weight_sync: false,
-  spoolman_report_partial_usage: true,
-  check_updates: false,
-  check_printer_firmware: false,
-  include_beta_updates: false,
-  language: 'en',
-  notification_language: 'en',
-  bed_cooled_threshold: 35,
-  ams_humidity_good: 40,
-  ams_humidity_fair: 60,
-  ams_temp_good: 28,
-  ams_temp_fair: 35,
-  ams_history_retention_days: 30,
-  per_printer_mapping_expanded: false,
-  date_format: 'system',
-  time_format: 'system',
-  default_printer_id: null,
-  virtual_printer_enabled: false,
-  virtual_printer_access_code: '',
-  virtual_printer_mode: 'immediate',
-  dark_style: 'classic',
-  dark_background: 'neutral',
-  dark_accent: 'green',
-  light_style: 'classic',
-  light_background: 'neutral',
-  light_accent: 'green',
-  ftp_retry_enabled: true,
-  ftp_retry_count: 3,
-  ftp_retry_delay: 2,
-  ftp_timeout: 30,
-  mqtt_enabled: false,
-  mqtt_broker: '',
-  mqtt_port: 1883,
-  mqtt_username: '',
-  mqtt_password: '',
-  mqtt_topic_prefix: 'bambuddy',
-  mqtt_use_tls: false,
-  external_url: '',
-  ha_enabled: false,
-  ha_url: '',
-  ha_token: '',
-  ha_url_from_env: false,
-  ha_token_from_env: false,
-  ha_env_managed: false,
-  library_archive_mode: 'ask',
-  library_disk_warning_gb: 5.0,
-  camera_view_mode: 'window',
-  preferred_slicer: 'bambu_studio',
-  prometheus_enabled: false,
-  prometheus_token: '',
-  low_stock_threshold: 20.0,
-};
-
-function setupCommonHandlers(spoolList: object[]) {
-  server.use(
-    http.get('/api/v1/settings/', () => HttpResponse.json(MOCK_SETTINGS)),
-    // getSpoolmanSettings calls /api/v1/settings/spoolman (not /api/v1/spoolman/settings)
-    http.get('/api/v1/settings/spoolman', () =>
-      HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '', spoolman_sync_mode: 'auto', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'true' })
-    ),
-    http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolList)),
-    http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
-    http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
-  );
-}
-
-describe('InventoryPage - deep-link ?spool= flow', () => {
-  const originalLocation = window.location.href;
-
-  afterEach(() => {
-    // Restore URL after each test
-    window.history.replaceState({}, '', originalLocation);
-  });
-
-  describe('scenario 1: spool is already in the loaded list', () => {
-    beforeEach(() => {
-      window.history.pushState({}, '', '/?spool=42');
-      setupCommonHandlers([BASE_SPOOL]);
-    });
-
-    it('removes ?spool= param from URL after handling', async () => {
-      render(<InventoryPageRouter />);
-
-      await waitFor(() => {
-        expect(window.location.search).not.toContain('spool=42');
-      });
-    });
-
-    it('opens the edit modal for the linked spool', async () => {
-      render(<InventoryPageRouter />);
-
-      // The SpoolFormModal should open — it renders material name in a heading or field
-      await waitFor(() => {
-        // Modal is open when the spool form inputs are visible
-        expect(screen.getAllByText(/PLA/i).length).toBeGreaterThan(0);
-      });
-    });
-  });
-
-  describe('scenario 2: targeted fetch (spool not in initial list)', () => {
-    beforeEach(() => {
-      window.history.pushState({}, '', '/?spool=42');
-      // List is empty; single-spool fetch returns the spool
-      setupCommonHandlers([]);
-      server.use(
-        http.get('/api/v1/inventory/spools/:id', ({ params }) => {
-          if (Number(params.id) === 42) {
-            return HttpResponse.json(BASE_SPOOL);
-          }
-          return HttpResponse.json({ detail: 'Not found' }, { status: 404 });
-        })
-      );
-    });
-
-    it('removes ?spool= param from URL after successful targeted fetch', async () => {
-      render(<InventoryPageRouter />);
-
-      await waitFor(() => {
-        expect(window.location.search).not.toContain('spool=42');
-      });
-    });
-  });
-
-  describe('scenario 3: spool not found (404)', () => {
-    beforeEach(() => {
-      window.history.pushState({}, '', '/?spool=9999');
-      setupCommonHandlers([]);
-      server.use(
-        http.get('/api/v1/inventory/spools/:id', () =>
-          HttpResponse.json({ detail: 'Not found' }, { status: 404 })
-        )
-      );
-    });
-
-    it('removes ?spool= param from URL on 404', async () => {
-      render(<InventoryPageRouter />);
-
-      await waitFor(() => {
-        expect(window.location.search).not.toContain('spool=9999');
-      });
-    });
-
-    it('shows an error notification when spool is not found', async () => {
-      render(<InventoryPageRouter />);
-
-      // Must render the exact i18n string for deepLinkSpoolNotFound (en: 'Spool not found').
-      // Using findByText fails the test if the toast is absent or uses the wrong key.
-      await screen.findByText('Spool not found');
-    });
-  });
-
-  describe('scenario 4: targeted fetch returns 5xx server error', () => {
-    beforeEach(() => {
-      window.history.pushState({}, '', '/?spool=42');
-      setupCommonHandlers([]);
-      server.use(
-        http.get('/api/v1/inventory/spools/:id', () =>
-          HttpResponse.json({ detail: 'Internal Server Error' }, { status: 500 })
-        )
-      );
-    });
-
-    it('shows the deepLinkFetchFailed toast on 5xx', async () => {
-      render(<InventoryPageRouter />);
-
-      // The deep-link query has a custom retry callback (up to 2 retries with
-      // exponential backoff) that overrides the test QueryClient's retry:false.
-      // Allow up to 6 s for the retries to exhaust before the toast appears.
-      await screen.findByText('Could not load spool — try again', {}, { timeout: 6000 });
-    });
-  });
-
-  describe('scenario 5 (T-Gap 8): deep-link works in Spoolman mode', () => {
-    beforeEach(() => {
-      window.history.pushState({}, '', '/?spool=42');
-      // Override settings with Spoolman enabled
-      server.use(
-        http.get('/api/v1/settings/', () =>
-          HttpResponse.json({ ...MOCK_SETTINGS, spoolman_enabled: true, spoolman_url: 'http://spoolman.local:7912' })
-        ),
-        http.get('/api/v1/settings/spoolman', () =>
-          HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://spoolman.local:7912', spoolman_sync_mode: 'auto', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'true' })
-        ),
-        // In Spoolman mode the component fetches from /api/v1/spoolman/inventory/spools
-        http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([BASE_SPOOL])),
-        http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
-        http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
-      );
-    });
-
-    it('removes ?spool= param from URL in Spoolman mode', async () => {
-      render(<InventoryPageRouter />);
-
-      await waitFor(() => {
-        expect(window.location.search).not.toContain('spool=42');
-      });
-    });
-
-    it('opens the edit modal for the linked local spool in Spoolman mode', async () => {
-      render(<InventoryPageRouter />);
-
-      await waitFor(() => {
-        expect(screen.getAllByText(/PLA/i).length).toBeGreaterThan(0);
-      });
-    });
-  });
-});

+ 0 - 165
frontend/src/__tests__/pages/InventoryPageSearch.test.ts

@@ -1,165 +0,0 @@
-/**
- * Unit tests for the global search filter used in InventoryPage.
- *
- * The filter is a pure client-side computation — replicated inline here
- * following the same pattern as InventoryPageGrouping.test.ts so no DOM
- * render or API mock is needed.
- *
- * Fields covered: brand, material, color_name, subtype, note,
- * slicer_filament_name, storage_location.
- */
-
-import { describe, it, expect } from 'vitest';
-import type { InventorySpool } from '../../api/client';
-import { filterSpoolsByQuery } from '../../utils/inventorySearch';
-
-function applySearch(spools: InventorySpool[], search: string): InventorySpool[] {
-  return filterSpoolsByQuery(spools, search);
-}
-
-function makeSpool(overrides: Partial<InventorySpool> & { id: number }): InventorySpool {
-  return {
-    material: 'PLA',
-    subtype: 'Basic',
-    brand: 'Bambu Lab',
-    color_name: 'White',
-    rgba: 'FFFFFFFF',
-    label_weight: 1000,
-    core_weight: 250,
-    core_weight_catalog_id: null,
-    weight_used: 0,
-    weight_locked: false,
-    slicer_filament: null,
-    slicer_filament_name: null,
-    nozzle_temp_min: null,
-    nozzle_temp_max: null,
-    note: null,
-    added_full: null,
-    last_used: null,
-    encode_time: null,
-    tag_uid: null,
-    tray_uuid: null,
-    data_origin: null,
-    tag_type: null,
-    archived_at: null,
-    created_at: '2025-01-01T00:00:00Z',
-    updated_at: '2025-01-01T00:00:00Z',
-    k_profiles: [],
-    cost_per_kg: null,
-    last_scale_weight: null,
-    last_weighed_at: null,
-    storage_location: null,
-    ...overrides,
-  };
-}
-
-describe('InventoryPage search filter', () => {
-  describe('storage_location', () => {
-    it('returns spools whose storage_location matches the query', () => {
-      const spools = [
-        makeSpool({ id: 1, storage_location: 'IKEA Regal' }),
-        makeSpool({ id: 2, storage_location: 'Kiste - PLA' }),
-        makeSpool({ id: 3, storage_location: 'Lagerregal' }),
-      ];
-      expect(applySearch(spools, 'IKEA').map((s) => s.id)).toEqual([1]);
-      expect(applySearch(spools, 'Kiste').map((s) => s.id)).toEqual([2]);
-      expect(applySearch(spools, 'regal').map((s) => s.id)).toEqual([1, 3]);
-    });
-
-    it('is case-insensitive for storage_location', () => {
-      const spools = [makeSpool({ id: 1, storage_location: 'IKEA Regal' })];
-      expect(applySearch(spools, 'ikea regal')).toHaveLength(1);
-      expect(applySearch(spools, 'IKEA REGAL')).toHaveLength(1);
-      expect(applySearch(spools, 'ikEa')).toHaveLength(1);
-    });
-
-    it('matches partial storage_location strings', () => {
-      const spools = [makeSpool({ id: 1, storage_location: 'Kiste - PLA' })];
-      expect(applySearch(spools, 'Kis')).toHaveLength(1);
-      expect(applySearch(spools, 'PLA')).toHaveLength(1);
-      expect(applySearch(spools, '- PLA')).toHaveLength(1);
-    });
-
-    it('does not crash when storage_location is null', () => {
-      const spools = [makeSpool({ id: 1, storage_location: null })];
-      expect(() => applySearch(spools, 'regal')).not.toThrow();
-      expect(applySearch(spools, 'regal')).toHaveLength(0);
-    });
-
-    it('excludes spools whose storage_location does not match', () => {
-      const spools = [
-        makeSpool({ id: 1, storage_location: 'IKEA Regal' }),
-        makeSpool({ id: 2, storage_location: 'Kiste - PLA' }),
-      ];
-      expect(applySearch(spools, 'IKEA').map((s) => s.id)).toEqual([1]);
-    });
-  });
-
-  describe('existing fields (regression)', () => {
-    it('still finds by brand', () => {
-      const spools = [
-        makeSpool({ id: 1, brand: 'Bambu Lab' }),
-        makeSpool({ id: 2, brand: 'Polymaker' }),
-      ];
-      expect(applySearch(spools, 'polymaker').map((s) => s.id)).toEqual([2]);
-    });
-
-    it('still finds by material', () => {
-      const spools = [
-        makeSpool({ id: 1, material: 'PLA' }),
-        makeSpool({ id: 2, material: 'PETG' }),
-      ];
-      expect(applySearch(spools, 'petg').map((s) => s.id)).toEqual([2]);
-    });
-
-    it('still finds by color_name', () => {
-      const spools = [
-        makeSpool({ id: 1, color_name: 'Jade White' }),
-        makeSpool({ id: 2, color_name: 'Black' }),
-      ];
-      expect(applySearch(spools, 'jade').map((s) => s.id)).toEqual([1]);
-    });
-
-    it('still finds by note', () => {
-      const spools = [
-        makeSpool({ id: 1, note: 'fast print only' }),
-        makeSpool({ id: 2, note: null }),
-      ];
-      expect(applySearch(spools, 'fast').map((s) => s.id)).toEqual([1]);
-    });
-
-    it('returns all spools when search is empty', () => {
-      const spools = [makeSpool({ id: 1 }), makeSpool({ id: 2 })];
-      expect(applySearch(spools, '')).toHaveLength(2);
-    });
-
-    it('returns empty array when nothing matches', () => {
-      const spools = [makeSpool({ id: 1, brand: 'Bambu Lab', material: 'PLA' })];
-      expect(applySearch(spools, 'xxxxxxxx')).toHaveLength(0);
-    });
-  });
-
-  describe('cross-field matching', () => {
-    it('matches a spool if any field contains the query', () => {
-      const spool = makeSpool({
-        id: 1,
-        brand: 'Bambu Lab',
-        material: 'PLA',
-        storage_location: 'IKEA Regal',
-      });
-      // Each individual field matches
-      expect(applySearch([spool], 'Bambu')).toHaveLength(1);
-      expect(applySearch([spool], 'PLA')).toHaveLength(1);
-      expect(applySearch([spool], 'IKEA')).toHaveLength(1);
-    });
-
-    it('a query matching only storage_location is found even when other fields do not match', () => {
-      const spools = [
-        makeSpool({ id: 1, brand: 'Polymaker', material: 'PETG', storage_location: 'IKEA Regal' }),
-        makeSpool({ id: 2, brand: 'Polymaker', material: 'PETG', storage_location: 'Kiste' }),
-      ];
-      const result = applySearch(spools, 'IKEA');
-      expect(result.map((s) => s.id)).toEqual([1]);
-    });
-  });
-});

+ 0 - 300
frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx

@@ -1,300 +0,0 @@
-/**
- * Tests for LOCATION column in InventoryPage when in Spoolman mode.
- *
- * Regression test for Phase 8: LOCATION column showed "-" for Spoolman
- * spools assigned to AMS slots, because only the local
- * /inventory/assignments endpoint was queried — the Spoolman
- * /spoolman/inventory/slot-assignments/all endpoint was ignored.
- */
-
-import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor, within } from '@testing-library/react';
-import { render } from '../utils';
-import InventoryPageRouter from '../../pages/InventoryPage';
-import { http, HttpResponse } from 'msw';
-import { server } from '../mocks/server';
-
-// Full settings shape — pattern matches InventoryPageLowStock.test.tsx.
-const mockSettings = {
-  auto_archive: true,
-  save_thumbnails: true,
-  capture_finish_photo: true,
-  default_filament_cost: 25.0,
-  currency: 'USD',
-  energy_cost_per_kwh: 0.15,
-  energy_tracking_mode: 'total',
-  spoolman_enabled: false,
-  spoolman_url: '',
-  spoolman_sync_mode: 'auto',
-  spoolman_disable_weight_sync: false,
-  spoolman_report_partial_usage: true,
-  check_updates: true,
-  check_printer_firmware: true,
-  include_beta_updates: false,
-  language: 'en',
-  notification_language: 'en',
-  bed_cooled_threshold: 35,
-  ams_humidity_good: 40,
-  ams_humidity_fair: 60,
-  ams_temp_good: 28,
-  ams_temp_fair: 35,
-  ams_history_retention_days: 30,
-  per_printer_mapping_expanded: false,
-  date_format: 'system',
-  time_format: 'system',
-  default_printer_id: null,
-  virtual_printer_enabled: false,
-  virtual_printer_access_code: '',
-  virtual_printer_mode: 'immediate',
-  dark_style: 'classic',
-  dark_background: 'neutral',
-  dark_accent: 'green',
-  light_style: 'classic',
-  light_background: 'neutral',
-  light_accent: 'green',
-  ftp_retry_enabled: true,
-  ftp_retry_count: 3,
-  ftp_retry_delay: 2,
-  ftp_timeout: 30,
-  mqtt_enabled: false,
-  mqtt_broker: '',
-  mqtt_port: 1883,
-  mqtt_username: '',
-  mqtt_password: '',
-  mqtt_topic_prefix: 'bambuddy',
-  mqtt_use_tls: false,
-  external_url: '',
-  ha_enabled: false,
-  ha_url: '',
-  ha_token: '',
-  ha_url_from_env: false,
-  ha_token_from_env: false,
-  ha_env_managed: false,
-  library_archive_mode: 'ask',
-  library_disk_warning_gb: 5.0,
-  camera_view_mode: 'window',
-  preferred_slicer: 'bambu_studio',
-  prometheus_enabled: false,
-  prometheus_token: '',
-  low_stock_threshold: 20.0,
-};
-
-const mockSpoolmanSpool = {
-  id: 216,
-  material: 'PLA',
-  subtype: null,
-  brand: 'Bambu Lab',
-  color_name: 'Orange',
-  rgba: 'FF8800FF',
-  label_weight: 1000,
-  core_weight: 250,
-  weight_used: 200,
-  slicer_filament: null,
-  slicer_filament_name: null,
-  nozzle_temp_min: null,
-  nozzle_temp_max: null,
-  note: null,
-  added_full: null,
-  last_used: null,
-  encode_time: null,
-  tag_uid: null,
-  tray_uuid: null,
-  data_origin: 'spoolman',
-  tag_type: 'spoolman',
-  archived_at: null,
-  created_at: '2025-01-01T00:00:00Z',
-  updated_at: '2025-01-01T00:00:00Z',
-  k_profiles: [],
-  cost_per_kg: null,
-  last_scale_weight: null,
-  last_weighed_at: null,
-  storage_location: 'IKEA Regal',
-};
-
-describe('InventoryPage - LOCATION column (Spoolman mode)', () => {
-  beforeEach(() => {
-    localStorage.clear();
-    server.use(
-      http.get('/api/v1/settings/', () => HttpResponse.json(mockSettings)),
-      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
-      http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
-    );
-  });
-
-  it('shows AMS slot in LOCATION column for spoolman spool with assignment', async () => {
-    server.use(
-      http.get('/api/v1/settings/spoolman', () =>
-        HttpResponse.json({
-          spoolman_enabled: 'true',
-          spoolman_url: 'http://localhost:7912',
-        })
-      ),
-      http.get('/api/v1/spoolman/inventory/spools', () =>
-        HttpResponse.json([mockSpoolmanSpool])
-      ),
-      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
-        HttpResponse.json([
-          {
-            printer_id: 1,
-            printer_name: 'Sully',
-            ams_id: 0,
-            tray_id: 2,
-            spoolman_spool_id: 216,
-            ams_label: null,
-          },
-        ])
-      ),
-    );
-
-    const { container } = render(<InventoryPageRouter />);
-
-    // LOCATION cell renders "{printerLabel} {slotLabel}" via JSX template,
-    // which splits into separate text nodes. Use innerHTML / textContent inspection.
-    // formatSlotLabel(0, 2, false, false) => "A3"
-    await waitFor(() => {
-      expect(container.textContent).toContain('Sully');
-      expect(container.textContent).toContain('A3');
-    });
-  });
-
-  it('shows "-" in LOCATION column when spoolman spool has no slot assignment', async () => {
-    server.use(
-      http.get('/api/v1/settings/spoolman', () =>
-        HttpResponse.json({
-          spoolman_enabled: 'true',
-          spoolman_url: 'http://localhost:7912',
-        })
-      ),
-      http.get('/api/v1/spoolman/inventory/spools', () =>
-        HttpResponse.json([mockSpoolmanSpool])
-      ),
-      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
-        HttpResponse.json([])
-      ),
-    );
-
-    const { container } = render(<InventoryPageRouter />);
-
-    await waitFor(() => {
-      // Spool row is rendered — brand "Bambu Lab" appears in the table somewhere.
-      expect(container.textContent).toContain('Bambu Lab');
-    });
-    // LOCATION cell shows "-" (there may be other "-" cells too — at least one expected).
-    const dashCells = screen.getAllByText('-');
-    expect(dashCells.length).toBeGreaterThan(0);
-  });
-
-  it('does not call /spoolman/inventory/slot-assignments/all in local mode', async () => {
-    let slotEndpointCalled = false;
-    server.use(
-      http.get('/api/v1/settings/spoolman', () =>
-        HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' })
-      ),
-      http.get('/api/v1/inventory/spools', () => HttpResponse.json([])),
-      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => {
-        slotEndpointCalled = true;
-        return HttpResponse.json([]);
-      }),
-    );
-
-    render(<InventoryPageRouter />);
-
-    // Wait for the page to settle by checking a stable element from the stat cards.
-    await waitFor(() => {
-      expect(screen.getByText(/total inventory/i)).toBeInTheDocument();
-    });
-    expect(slotEndpointCalled).toBe(false);
-  });
-
-  it('counts spoolman slot assignments in the IN PRINTER stat card', async () => {
-    server.use(
-      http.get('/api/v1/settings/spoolman', () =>
-        HttpResponse.json({
-          spoolman_enabled: 'true',
-          spoolman_url: 'http://localhost:7912',
-        })
-      ),
-      http.get('/api/v1/spoolman/inventory/spools', () =>
-        HttpResponse.json([mockSpoolmanSpool])
-      ),
-      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
-        HttpResponse.json([
-          {
-            printer_id: 1,
-            printer_name: 'Sully',
-            ams_id: 0,
-            tray_id: 2,
-            spoolman_spool_id: 216,
-            ams_label: null,
-          },
-        ])
-      ),
-    );
-
-    render(<InventoryPageRouter />);
-
-    // Find the "IN PRINTER" stat card by its label, then assert the count "1"
-    // appears within the same stat-card div. This verifies the inPrinterCount
-    // sums Spoolman slot assignments (was 0 before Phase 8).
-    await waitFor(() => {
-      const label = screen.getByText(/^in printer$/i);
-      const card = label.closest('div.bg-bambu-dark-secondary');
-      expect(card).not.toBeNull();
-      expect(within(card as HTMLElement).getByText('1')).toBeInTheDocument();
-    });
-  });
-
-  it('local SpoolAssignment wins over Spoolman slot assignment on id collision', async () => {
-    // Both endpoints return an entry with the same numeric id (216). The local
-    // /inventory/assignments source must win — printer_name "LocalPrinter" and
-    // slot "B1" (formatSlotLabel(1, 0, false, false)) — and the Spoolman entry
-    // ("SpoolmanPrinter" / "C4") must NOT appear.
-    server.use(
-      http.get('/api/v1/settings/spoolman', () =>
-        HttpResponse.json({
-          spoolman_enabled: 'true',
-          spoolman_url: 'http://localhost:7912',
-        })
-      ),
-      http.get('/api/v1/spoolman/inventory/spools', () =>
-        HttpResponse.json([mockSpoolmanSpool])
-      ),
-      http.get('/api/v1/inventory/assignments', () =>
-        HttpResponse.json([
-          {
-            id: 99,
-            spool_id: 216,
-            printer_id: 7,
-            printer_name: 'LocalPrinter',
-            ams_id: 1,
-            tray_id: 0,
-            ams_label: null,
-            created_at: '2025-01-01T00:00:00Z',
-          },
-        ])
-      ),
-      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
-        HttpResponse.json([
-          {
-            printer_id: 8,
-            printer_name: 'SpoolmanPrinter',
-            ams_id: 2,
-            tray_id: 3,
-            spoolman_spool_id: 216,
-            ams_label: null,
-          },
-        ])
-      ),
-    );
-
-    const { container } = render(<InventoryPageRouter />);
-
-    await waitFor(() => {
-      // Local printer wins
-      expect(container.textContent).toContain('LocalPrinter');
-      expect(container.textContent).toContain('B1');
-    });
-    expect(container.textContent).not.toContain('SpoolmanPrinter');
-    expect(container.textContent).not.toContain('C4');
-  });
-});

+ 0 - 317
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -917,321 +917,4 @@ describe('PrintersPage', () => {
       expect(screen.queryByRole('button', { name: /all locations/i })).not.toBeInTheDocument();
     });
   });
-
-  describe('Spoolman loading guard', () => {
-    it('does not show Assign Spool button while Spoolman queries are loading', async () => {
-      // Spoolman enabled but inventory and slot-assignment queries never resolve
-      server.use(
-        http.get('/api/v1/spoolman/status', () =>
-          HttpResponse.json({ enabled: true, connected: true })
-        ),
-        http.get('/api/v1/spoolman/inventory/spools', () =>
-          new Promise(() => {})  // never resolves
-        ),
-        http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
-          new Promise(() => {})  // never resolves
-        )
-      );
-
-      render(<PrintersPage />);
-
-      // Wait for the page to render (printers should be visible)
-      await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
-
-      // While Spoolman queries are still loading, the "Assign Spool" button must
-      // not appear (inventory prop is undefined → {inventory && ...} guard fires)
-      expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();
-    });
-  });
-
-});
-
-/**
- * Phase 13 P13-1 (PrintersPage EmptySlotHoverCard onAssignSpool gate removal)
- *
- * Pre-Phase-13 each of the three EmptySlotHoverCard call-sites in PrintersPage
- * gated `onAssignSpool` on `spoolmanEnabled ? (...) : undefined`, so empty
- * slots in local-Inventory mode never showed an Assign action. Maintainer
- * Foto 7 confirmed users expect the button regardless of mode.
- *
- * To assert wiring without going through hover-card animations, we mock the
- * EmptySlotHoverCard component at module level and capture every props
- * payload. The same mock is active in both modes; tests differ only in the
- * spoolman-settings mock. The mock module covers BOTH FilamentHoverCard exports
- * so tests outside this `describe` aren't affected (we re-export the real
- * FilamentHoverCard).
- */
-const phase13EmptySlotProps: Array<Record<string, unknown>> = [];
-const phase14HoverCardProps: Array<Record<string, unknown>> = [];
-
-vi.mock('../../components/FilamentHoverCard', async (importOriginal) => {
-  const actual = await importOriginal<typeof import('../../components/FilamentHoverCard')>();
-  return {
-    ...actual,
-    EmptySlotHoverCard: (props: Record<string, unknown>) => {
-      phase13EmptySlotProps.push({ ...props });
-      return null;
-    },
-    FilamentHoverCard: (props: Record<string, unknown>) => {
-      phase14HoverCardProps.push({ ...props });
-      return null;
-    },
-  };
-});
-
-describe('PrintersPage Phase 13 — EmptySlotHoverCard onAssignSpool wiring', () => {
-  beforeEach(() => {
-    phase13EmptySlotProps.length = 0;
-    localStorage.removeItem('printerCardSize');
-
-    server.use(
-      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
-      // Status response includes an empty AMS slot so EmptySlotHoverCard renders.
-      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
-        ...mockPrinterStatus,
-        ams: [{
-          id: 0,
-          tray: [{ id: 0, tray_type: '' }],
-        }],
-      })),
-      http.get('/api/v1/settings/', () => HttpResponse.json({
-        auto_archive: true, save_thumbnails: true, capture_finish_photo: true,
-        default_filament_cost: 25.0, currency: 'USD',
-        ams_humidity_good: 40, ams_humidity_fair: 60,
-        ams_temp_good: 30, ams_temp_fair: 35,
-      })),
-      http.get('/api/v1/queue/', () => HttpResponse.json([])),
-    );
-  });
-
-  it('P13-1 (local mode): EmptySlotHoverCard receives onAssignSpool callback', async () => {
-    server.use(
-      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
-        spoolman_enabled: 'false', spoolman_url: '',
-      })),
-    );
-    render(<PrintersPage />);
-
-    // Wait for printer status to load and at least one EmptySlotHoverCard
-    // to mount with an onAssignSpool callback. Pre-Phase-13 this would have
-    // been undefined in local mode (the gate filtered it out).
-    await waitFor(() => {
-      const withCallback = phase13EmptySlotProps.filter(p => typeof p.onAssignSpool === 'function');
-      expect(withCallback.length).toBeGreaterThan(0);
-    }, { timeout: 3000 });
-  });
-
-  it('P13-1 (spoolman mode): EmptySlotHoverCard still receives onAssignSpool callback', async () => {
-    server.use(
-      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
-        spoolman_enabled: 'true', spoolman_url: 'http://x:7912',
-      })),
-      http.get('/api/v1/spoolman/spools/inventory*', () => HttpResponse.json([])),
-      http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([])),
-      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
-    );
-    render(<PrintersPage />);
-
-    await waitFor(() => {
-      const withCallback = phase13EmptySlotProps.filter(p => typeof p.onAssignSpool === 'function');
-      expect(withCallback.length).toBeGreaterThan(0);
-    }, { timeout: 3000 });
-  });
-});
-
-/**
- * Phase 14 — Local-Branch BL-detection symmetry.
- *
- * The Spoolman branch of every IIFE in PrintersPage already passes
- *   isAssigned: !!slotAssignment || isBambuLabSpool(tray)
- *   onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? ... : undefined
- *
- * The local branch was missing both. As a result a BL-RFID-tagged slot in
- * local-Inventory mode showed an "Assign Spool" button (because no manual
- * SpoolAssignment exists), and a manually-assigned BL-RFID slot showed
- * "Unassign" — which would be overwritten on the next RFID re-read.
- *
- * The same FilamentHoverCard mock from the Phase 13 block above captures
- * inventory props on every render so we can inspect them after setup.
- */
-describe('PrintersPage Phase 14 — Local-Branch BL-detection symmetry', () => {
-  beforeEach(() => {
-    phase14HoverCardProps.length = 0;
-    localStorage.removeItem('printerCardSize');
-
-    server.use(
-      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
-      http.get('/api/v1/settings/', () => HttpResponse.json({
-        auto_archive: true, save_thumbnails: true, capture_finish_photo: true,
-        default_filament_cost: 25.0, currency: 'USD',
-        ams_humidity_good: 40, ams_humidity_fair: 60,
-        ams_temp_good: 30, ams_temp_fair: 35,
-      })),
-      http.get('/api/v1/queue/', () => HttpResponse.json([])),
-      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
-        spoolman_enabled: 'false', spoolman_url: '',
-      })),
-    );
-  });
-
-  it('P14-1a (local + BL-RFID + no assignment): inventory.isAssigned=true', async () => {
-    server.use(
-      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
-        ...mockPrinterStatus,
-        ams: [{
-          id: 0,
-          tray: [{
-            id: 0,
-            tray_type: 'PLA',
-            tray_uuid: '11223344556677880011223344556677',
-            tag_uid: '0000000000000000',
-            tray_color: 'FF0000FF',
-            tray_sub_brands: 'Bambu PLA Basic',
-          }],
-        }],
-      })),
-      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
-    );
-    render(<PrintersPage />);
-
-    await waitFor(() => {
-      const matches = phase14HoverCardProps.filter(
-        p => (p.inventory as { isAssigned?: boolean } | undefined)?.isAssigned === true
-      );
-      expect(matches.length).toBeGreaterThan(0);
-    }, { timeout: 3000 });
-  });
-
-  it('P14-1b (local + non-BL + no assignment): inventory.isAssigned is falsy', async () => {
-    server.use(
-      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
-        ...mockPrinterStatus,
-        ams: [{
-          id: 0,
-          tray: [{
-            id: 0,
-            tray_type: 'PLA',
-            tray_uuid: '00000000000000000000000000000000',
-            tag_uid: '0000000000000000',
-            tray_color: 'FF0000FF',
-            tray_sub_brands: 'Generic PLA',
-          }],
-        }],
-      })),
-      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
-    );
-    render(<PrintersPage />);
-
-    // Wait for FilamentHoverCard to render at least once.
-    await waitFor(() => {
-      expect(phase14HoverCardProps.length).toBeGreaterThan(0);
-    }, { timeout: 3000 });
-
-    // No render should ever set isAssigned=true for this slot.
-    const truthyMatches = phase14HoverCardProps.filter(
-      p => (p.inventory as { isAssigned?: boolean } | undefined)?.isAssigned === true
-    );
-    expect(truthyMatches.length).toBe(0);
-  });
-
-  it('P14-1c (local + manual assignment): inventory.isAssigned=true', async () => {
-    server.use(
-      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
-        ...mockPrinterStatus,
-        ams: [{
-          id: 0,
-          tray: [{
-            id: 0,
-            tray_type: 'PLA',
-            tray_uuid: '00000000000000000000000000000000',
-            tag_uid: '0000000000000000',
-            tray_color: 'FF0000FF',
-            tray_sub_brands: 'Generic PLA',
-          }],
-        }],
-      })),
-      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([
-        {
-          id: 1,
-          spool_id: 42,
-          printer_id: 1,
-          ams_id: 0,
-          tray_id: 0,
-          printer_name: 'X1 Carbon',
-          ams_label: null,
-          spool: {
-            id: 42,
-            material: 'PLA',
-            brand: 'Generic',
-            color_name: 'Red',
-            label_weight: 1000,
-            weight_used: 0,
-            rgba: 'FF0000FF',
-          },
-        },
-      ])),
-    );
-    render(<PrintersPage />);
-
-    await waitFor(() => {
-      const matches = phase14HoverCardProps.filter(
-        p => (p.inventory as { isAssigned?: boolean } | undefined)?.isAssigned === true
-      );
-      expect(matches.length).toBeGreaterThan(0);
-    }, { timeout: 3000 });
-  });
-
-  it('P14-2 (local + BL-RFID + manual assignment): onUnassignSpool=undefined', async () => {
-    server.use(
-      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
-        ...mockPrinterStatus,
-        ams: [{
-          id: 0,
-          tray: [{
-            id: 0,
-            tray_type: 'PLA',
-            tray_uuid: '11223344556677880011223344556677',
-            tag_uid: '0000000000000000',
-            tray_color: 'FF0000FF',
-            tray_sub_brands: 'Bambu PLA Basic',
-          }],
-        }],
-      })),
-      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([
-        {
-          id: 1,
-          spool_id: 42,
-          printer_id: 1,
-          ams_id: 0,
-          tray_id: 0,
-          printer_name: 'X1 Carbon',
-          ams_label: null,
-          spool: {
-            id: 42,
-            material: 'PLA',
-            brand: 'Bambu Lab',
-            color_name: 'Red',
-            label_weight: 1000,
-            weight_used: 0,
-            rgba: 'FF0000FF',
-          },
-        },
-      ])),
-    );
-    render(<PrintersPage />);
-
-    // Wait for FilamentHoverCard renders to settle.
-    await waitFor(() => {
-      expect(phase14HoverCardProps.length).toBeGreaterThan(0);
-    }, { timeout: 3000 });
-
-    // For BL-detected slots in local mode, onUnassignSpool must always be
-    // undefined — even when a manual assignment exists. Otherwise the user
-    // could unassign a BL-RFID slot that the printer would re-assign on the
-    // next re-read, surprising them with phantom ghost-assignments.
-    const definedUnassign = phase14HoverCardProps.filter(
-      p => typeof (p.inventory as { onUnassignSpool?: () => void } | undefined)?.onUnassignSpool === 'function'
-    );
-    expect(definedUnassign.length).toBe(0);
-  });
 });

+ 0 - 447
frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx

@@ -1,447 +0,0 @@
-/**
- * Tests for SpoolBuddyAmsPage Phase 13 changes — full component-render integration.
- *
- * Renders the actual SpoolBuddyAmsPage with mocks and asserts on the new wiring
- * introduced by Phase 13:
- * - P13-1d:  SlotActionPicker shows Local-Assign action on empty slots (local mode)
- * - P13-4:   AssignSpoolModal receives spoolmanEnabled prop from parent
- * - P13-5:   unlinkSpoolMutation invalidates all 5 dependent query keys
- * - P13-6a:  spoolmanSlotAssignmentsAll + spoolmanInventorySpoolsCache queries fire when spoolmanEnabled
- * - P13-6b:  Slot-assigned-only Spoolman spool produces a fill bar
- * - P13-6c:  SlotActionPicker hides Link button when slot has SpoolmanSlotAssignment
- *
- * SpoolSlot tiles render as <div onClick=... title="AMS Slot N">; tests target
- * them via getByTitle which is a stable, semantic selector. Buttons inside the
- * SlotActionPicker are addressed by their visible text (translated via the
- * mocked react-i18next; t-fallback returns the second arg).
- */
-
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
-import React from 'react';
-import { render } from '@testing-library/react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
-import { ToastProvider } from '../../contexts/ToastContext';
-import { SpoolBuddyAmsPage } from '../../pages/spoolbuddy/SpoolBuddyAmsPage';
-
-// Capture every props payload that AssignSpoolModal receives so tests can
-// inspect spoolmanEnabled threading. Render nothing — we don't need to render
-// the modal contents; we only verify the parent wires the prop correctly.
-const assignSpoolModalCalls: Array<Record<string, unknown>> = [];
-vi.mock('../../components/AssignSpoolModal', () => ({
-  AssignSpoolModal: (props: Record<string, unknown>) => {
-    if (props.isOpen) assignSpoolModalCalls.push({ ...props });
-    return null;
-  },
-}));
-
-vi.mock('../../components/LinkSpoolModal', () => ({
-  LinkSpoolModal: () => null,
-}));
-
-vi.mock('../../components/ConfigureAmsSlotModal', () => ({
-  ConfigureAmsSlotModal: () => null,
-}));
-
-// Tracks whether each mocked endpoint was actually called — used by P13-6a.
-const apiCallCounts: Record<string, number> = {};
-function counter(name: string) {
-  return () => {
-    apiCallCounts[name] = (apiCallCounts[name] ?? 0) + 1;
-    return Promise.resolve(apiResponses[name]);
-  };
-}
-
-let apiResponses: Record<string, unknown> = {};
-let spoolmanStatusValue: { enabled: boolean; connected: boolean } = { enabled: false, connected: false };
-
-vi.mock('../../api/client', () => ({
-  api: new Proxy({} as Record<string, unknown>, {
-    get: (_t, p: string) => {
-      if (p === 'getSpoolmanStatus') return () => Promise.resolve(spoolmanStatusValue);
-      if (p === 'unlinkSpool') return apiResponses.unlinkSpool ?? (() => Promise.resolve({ success: true }));
-      if (p in apiResponses) {
-        // Most endpoints just return the canned response.
-        if (typeof apiResponses[p] === 'function') return apiResponses[p];
-        return counter(p);
-      }
-      // Default to no-op resolved promise so unrelated calls don't crash.
-      return () => Promise.resolve(null);
-    },
-  }),
-}));
-
-vi.mock('react-i18next', () => ({
-  useTranslation: () => ({
-    t: (key: string, fallback?: string | Record<string, unknown>) => {
-      if (typeof fallback === 'string') return fallback;
-      return key;
-    },
-    i18n: { language: 'en', changeLanguage: vi.fn() },
-  }),
-}));
-
-const mockShowToast = vi.fn();
-vi.mock('../../contexts/ToastContext', async (importOriginal) => {
-  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
-  return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
-});
-
-const baseOutletContext = {
-  selectedPrinterId: 1,
-  setSelectedPrinterId: vi.fn(),
-  sbState: {
-    weight: null,
-    weightStable: false,
-    rawAdc: null,
-    matchedSpool: null,
-    unknownTagUid: null,
-    unknownTrayUuid: null,
-    deviceOnline: true,
-    deviceId: 'dev-1',
-    remainingWeight: null,
-    netWeight: null,
-  },
-  setAlert: vi.fn(),
-  displayBrightness: 100,
-  setDisplayBrightness: vi.fn(),
-  displayBlankTimeout: 0,
-  setDisplayBlankTimeout: vi.fn(),
-};
-
-let lastQueryClient: QueryClient | null = null;
-
-function renderPage() {
-  function Wrapper() {
-    return <Outlet context={baseOutletContext} />;
-  }
-  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
-  lastQueryClient = qc;
-  return render(
-    <ToastProvider>
-      <QueryClientProvider client={qc}>
-        <MemoryRouter initialEntries={['/spoolbuddy/ams']}>
-          <Routes>
-            <Route element={<Wrapper />}>
-              <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
-            </Route>
-          </Routes>
-        </MemoryRouter>
-      </QueryClientProvider>
-    </ToastProvider>,
-  );
-}
-
-/**
- * Returns a printer status payload with one regular AMS containing four trays.
- * Each tray's content is overridable via the per-slot opts arg so individual
- * tests can shape exactly the state they need.
- */
-function buildPrinterStatus(opts: {
-  slot0?: Partial<Record<string, unknown>>;
-  slot1?: Partial<Record<string, unknown>>;
-  slot2?: Partial<Record<string, unknown>>;
-  slot3?: Partial<Record<string, unknown>>;
-} = {}) {
-  const empty = { tray_type: '' };
-  const blDefault = {
-    tray_type: 'PLA',
-    tray_sub_brands: 'PLA Basic',
-    tray_color: 'FF0000FF',
-    tray_uuid: '11223344556677880011223344556677',
-    tag_uid: 'AABBCC1122334400',
-    tray_info_idx: 'GFL05',
-    remain: 80,
-    cali_idx: 5,
-  };
-  return {
-    connected: true,
-    state: 'IDLE',
-    ams: [{
-      id: 0, humidity: 30, temp: 25,
-      tray: [
-        { id: 0, ...blDefault, ...(opts.slot0 ?? {}) },
-        { id: 1, ...empty, ...(opts.slot1 ?? {}) },
-        { id: 2, ...empty, ...(opts.slot2 ?? {}) },
-        { id: 3, ...empty, ...(opts.slot3 ?? {}) },
-      ],
-    }],
-    vt_tray: [],
-    tray_now: 255,
-    active_extruder: 0,
-  };
-}
-
-function setupDefaultApiResponses() {
-  apiResponses = {
-    getPrinterStatus: buildPrinterStatus(),
-    getPrinter: { id: 1, name: 'Test', serial_number: 'SN1', nozzle_count: 1 },
-    getSlotPresets: {},
-    getSettings: {},
-    getLinkedSpools: { linked: {} },
-    getAssignments: [],
-    getSpoolmanSlotAssignments: [],
-    getSpoolmanInventorySpools: [],
-    unlinkSpool: vi.fn().mockResolvedValue({ success: true, message: 'unlinked' }),
-  };
-}
-
-describe('SpoolBuddyAmsPage Phase 13', () => {
-  beforeEach(() => {
-    assignSpoolModalCalls.length = 0;
-    Object.keys(apiCallCounts).forEach(k => delete apiCallCounts[k]);
-    setupDefaultApiResponses();
-    spoolmanStatusValue = { enabled: false, connected: false };
-    vi.clearAllMocks();
-    mockShowToast.mockClear();
-  });
-
-  describe('P13-4 — AssignSpoolModal receives spoolmanEnabled prop', () => {
-    it('passes spoolmanEnabled=false in local mode', async () => {
-      spoolmanStatusValue = { enabled: false, connected: false };
-      renderPage();
-
-      // Use an empty slot (Slot 2 = tray_id 1) so the Phase-14 BL-detection
-      // gate doesn't suppress the Assign-Spool action. Slot 1 carries
-      // blDefault with a non-zero tray_uuid in buildPrinterStatus, which
-      // is correctly recognized as BL-RFID and offers Configure only.
-      const slot2 = await screen.findByTitle('AMS Slot 2');
-      fireEvent.click(slot2);
-
-      // SlotActionPicker opens; click the Assign-Spool action ("Track a spool from your inventory")
-      const assignAction = await screen.findByText('Track a spool from your inventory');
-      fireEvent.click(assignAction);
-
-      await waitFor(() => {
-        expect(assignSpoolModalCalls.length).toBeGreaterThan(0);
-      });
-      const lastCall = assignSpoolModalCalls[assignSpoolModalCalls.length - 1];
-      expect(lastCall.spoolmanEnabled).toBe(false);
-    });
-
-    it('passes spoolmanEnabled=true in spoolman mode', async () => {
-      spoolmanStatusValue = { enabled: true, connected: true };
-      renderPage();
-
-      // In Spoolman mode the Local-Assign action is gated off (Z.704 of
-      // SpoolBuddyAmsPage.tsx: `!spoolmanEnabled && (assignment ? ...)`).
-      // So we trigger the modal indirectly by ensuring the prop threads
-      // correctly when the modal does open via the link path. Since the
-      // local-assign action is unreachable in Spoolman mode by design, the
-      // most reliable assertion is to verify the picker renders at all and
-      // that the test environment matches Spoolman mode — the prop wiring
-      // itself is validated by the asymmetry: previously prop=undefined led
-      // to the modal showing both inventories. With the fix, in spoolman
-      // mode the prop is true (we'd see no local-list rendered if any
-      // assign-modal opened — which it can't from this picker in this mode).
-      const slot1 = await screen.findByTitle('AMS Slot 1');
-      fireEvent.click(slot1);
-      // The picker opens with the Configure button (always visible)
-      await screen.findByText('Set filament preset, K-profile, and color');
-      // No Local-Assign action in Spoolman mode (the gate is `!spoolmanEnabled`)
-      expect(screen.queryByText('Track a spool from your inventory')).not.toBeInTheDocument();
-    });
-  });
-
-  describe('P13-5 — unlinkSpoolMutation invalidates all 5 dependent query keys', () => {
-    it('invalidates linked-spools, unlinked-spools, spoolman-slot-assignments, spoolman-slot-assignments-all, spoolman-inventory-spools', async () => {
-      spoolmanStatusValue = { enabled: true, connected: true };
-      // BL slot 0 has tray_uuid that maps to a linked spool — so the unlink
-      // button is reachable in the picker.
-      apiResponses.getLinkedSpools = {
-        linked: {
-          '11223344556677880011223344556677': {
-            id: 42,
-            material: 'PLA',
-            color_name: 'Red',
-            rgba: 'FF0000FF',
-            remaining_weight: 800,
-            filament_weight: 1000,
-          },
-        },
-      };
-      const unlinkResolve = vi.fn().mockResolvedValue({ success: true, message: 'unlinked' });
-      apiResponses.unlinkSpool = unlinkResolve;
-
-      renderPage();
-      const qc = lastQueryClient!;
-      const invalidateSpy = vi.spyOn(qc, 'invalidateQueries');
-
-      const slot1 = await screen.findByTitle('AMS Slot 1');
-      fireEvent.click(slot1);
-
-      const unlinkBtn = await screen.findByText('Remove Spoolman link from this slot');
-      fireEvent.click(unlinkBtn);
-
-      // After mutation resolves, all 5 expected keys must be invalidated.
-      await waitFor(() => {
-        const invalidatedKeys = invalidateSpy.mock.calls
-          .map(c => (c[0] as { queryKey?: readonly unknown[] })?.queryKey?.[0])
-          .filter(Boolean);
-        expect(invalidatedKeys).toEqual(expect.arrayContaining([
-          'linked-spools',
-          'unlinked-spools',
-          'spoolman-slot-assignments',
-          'spoolman-slot-assignments-all',
-          'spoolman-inventory-spools',
-        ]));
-      });
-      expect(unlinkResolve).toHaveBeenCalledWith(42);
-    });
-  });
-
-  describe('P13-6a — Spoolman queries fire when spoolmanEnabled', () => {
-    it('fetches spoolman-slot-assignments-all and spoolman-inventory-spools when spoolman is enabled', async () => {
-      spoolmanStatusValue = { enabled: true, connected: true };
-      renderPage();
-
-      // Wait for the page to settle and queries to fire
-      await screen.findByTitle('AMS Slot 1');
-      await waitFor(() => {
-        expect(apiCallCounts.getSpoolmanSlotAssignments ?? 0).toBeGreaterThan(0);
-        expect(apiCallCounts.getSpoolmanInventorySpools ?? 0).toBeGreaterThan(0);
-      });
-    });
-
-    it('does NOT fetch spoolman queries when spoolman is disabled', async () => {
-      spoolmanStatusValue = { enabled: false, connected: false };
-      renderPage();
-
-      await screen.findByTitle('AMS Slot 1');
-      // Wait an extra tick for any pending queries that might fire
-      await new Promise(r => setTimeout(r, 100));
-      expect(apiCallCounts.getSpoolmanSlotAssignments ?? 0).toBe(0);
-      expect(apiCallCounts.getSpoolmanInventorySpools ?? 0).toBe(0);
-    });
-  });
-
-  describe('P13-6c — SlotActionPicker Link button hidden when slot has SpoolmanSlotAssignment', () => {
-    it('hides Link button when this slot has a SpoolmanSlotAssignment but no tag-link', async () => {
-      spoolmanStatusValue = { enabled: true, connected: true };
-      // Slot 1 (tray_id=1) is empty per default — assign a Spoolman spool to it.
-      apiResponses.getSpoolmanSlotAssignments = [
-        { printer_id: 1, ams_id: 0, tray_id: 1, spoolman_spool_id: 42 },
-      ];
-      apiResponses.getSpoolmanInventorySpools = [
-        { id: 42, material: 'PLA', label_weight: 1000, weight_used: 200 },
-      ];
-      // Empty linked-spools so there's no tag-link path competing
-      apiResponses.getLinkedSpools = { linked: {} };
-
-      renderPage();
-
-      // Click slot 2 (tray_id=1, second slot) — has SpoolmanSlotAssignment but no tag link.
-      const slot2 = await screen.findByTitle('AMS Slot 2');
-      fireEvent.click(slot2);
-
-      // Configure button is always visible
-      await screen.findByText('Set filament preset, K-profile, and color');
-      // The Link-to-Spoolman action ("Link a Spoolman spool to this slot") must NOT show
-      expect(screen.queryByText('Link a Spoolman spool to this slot')).not.toBeInTheDocument();
-    });
-
-    it('shows Link button when slot has no SpoolmanSlotAssignment AND no tag-link', async () => {
-      spoolmanStatusValue = { enabled: true, connected: true };
-      // No slot assignments, no linked spools — slot is truly empty
-      apiResponses.getSpoolmanSlotAssignments = [];
-      apiResponses.getSpoolmanInventorySpools = [];
-      apiResponses.getLinkedSpools = { linked: {} };
-
-      renderPage();
-
-      // Click slot 2 (empty)
-      const slot2 = await screen.findByTitle('AMS Slot 2');
-      fireEvent.click(slot2);
-
-      // Link button SHOULD appear in this case
-      await screen.findByText('Link a Spoolman spool to this slot');
-    });
-  });
-});
-
-/**
- * P13-T-FE-1d — Empty slot shows Local-Assign action in local mode.
- *
- * Pre-Phase-13 the SlotActionPicker had `slotActionPicker?.tray && (assign...)`
- * which omitted the Assign action for empty slots. Maintainer wanted assign on
- * empty slots too. Verified by clicking an empty slot and asserting the
- * "Track a spool from your inventory" action is reachable.
- */
-describe('SpoolBuddyAmsPage P13-1d — Empty slot Local-Assign in local mode', () => {
-  beforeEach(() => {
-    assignSpoolModalCalls.length = 0;
-    Object.keys(apiCallCounts).forEach(k => delete apiCallCounts[k]);
-    setupDefaultApiResponses();
-    spoolmanStatusValue = { enabled: false, connected: false };
-    vi.clearAllMocks();
-    mockShowToast.mockClear();
-  });
-
-  it('shows Local-Assign action when clicking an empty slot in local mode', async () => {
-    spoolmanStatusValue = { enabled: false, connected: false };
-    renderPage();
-
-    // Slot 2 (tray_id=1) is empty by default in buildPrinterStatus()
-    const emptySlot = await screen.findByTitle('AMS Slot 2');
-    fireEvent.click(emptySlot);
-
-    // The Assign-Spool action must be visible in the picker
-    await screen.findByText('Track a spool from your inventory');
-  });
-});
-
-/**
- * Phase 14 — SlotActionPicker BL-detection symmetry in local mode.
- *
- * The Spoolman branch of SlotActionPicker (Z.775+) already suppresses the
- * Link-button when the slot is owned (hasSpoolmanAssignment). The local
- * branch had no equivalent — clicking a BL-RFID slot in local-Inventory
- * mode showed an "Assign Spool" action (or, with manual assignment,
- * "Unassign"), both of which would be undone by the printer's next
- * RFID re-read.
- *
- * Phase 14 wraps the local branch in an IIFE that returns null on
- * isBambuLabSpool(slotActionPicker?.tray) — neither Assign nor Unassign
- * is offered. The Configure action stays visible in all cases (it sets
- * filament preset / K-profile, which IS legitimate even on RFID slots).
- */
-describe('SpoolBuddyAmsPage Phase 14 — SlotActionPicker BL-detection in local mode', () => {
-  beforeEach(() => {
-    assignSpoolModalCalls.length = 0;
-    Object.keys(apiCallCounts).forEach(k => delete apiCallCounts[k]);
-    setupDefaultApiResponses();
-    spoolmanStatusValue = { enabled: false, connected: false };
-    vi.clearAllMocks();
-    mockShowToast.mockClear();
-  });
-
-  it('hides Assign and Unassign actions when clicking a BL-RFID slot in local mode', async () => {
-    spoolmanStatusValue = { enabled: false, connected: false };
-    // Slot 0 is BL-RFID by default (buildPrinterStatus blDefault has 32-hex tray_uuid)
-    renderPage();
-
-    const blSlot = await screen.findByTitle('AMS Slot 1');
-    fireEvent.click(blSlot);
-
-    // Configure must remain — it's a legitimate operation on BL-RFID slots.
-    await screen.findByText('Set filament preset, K-profile, and color');
-
-    // Both Assign and Unassign descriptions must be absent.
-    expect(screen.queryByText('Track a spool from your inventory')).toBeNull();
-    expect(screen.queryByText('Remove inventory spool from this slot')).toBeNull();
-  });
-
-  it('still shows Assign action on a non-BL empty slot (P13-1d regression)', async () => {
-    spoolmanStatusValue = { enabled: false, connected: false };
-    renderPage();
-
-    // Slot 2 (tray_id=1) is empty (tray_type=''), which means the SlotActionPicker
-    // sees tray=null per handleAmsSlotClick. isBambuLabSpool(null) returns false,
-    // so the Assign action must still appear.
-    const emptySlot = await screen.findByTitle('AMS Slot 2');
-    fireEvent.click(emptySlot);
-
-    await screen.findByText('Track a spool from your inventory');
-  });
-});

+ 7 - 349
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -7,7 +7,7 @@
  */
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent, act } from '@testing-library/react';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
 import React from 'react';
 import { render } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -15,28 +15,17 @@ import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
 import { SpoolBuddyDashboard } from '../../pages/spoolbuddy/SpoolBuddyDashboard';
 import { ToastProvider } from '../../contexts/ToastContext';
 
-const mockShowToast = vi.fn();
-vi.mock('../../contexts/ToastContext', async (importOriginal) => {
-  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
-  return { ...actual, useToast: () => ({ showToast: mockShowToast }) };
-});
-
 vi.mock('../../api/client', () => ({
   api: {
     getSpools: vi.fn().mockResolvedValue([
-      { id: 1, material: 'PLA', brand: 'Bambu', tag_uid: 'AA:BB', tray_uuid: null, archived_at: null, color_name: 'Red', rgba: 'FF0000FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 100 },
-      { id: 2, material: 'PETG', brand: 'Bambu', tag_uid: 'CC:DD', tray_uuid: null, archived_at: null, color_name: 'Blue', rgba: '0000FFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 200 },
-      { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
+      { id: 1, material: 'PLA', brand: 'Bambu', tag_uid: 'AA:BB', archived_at: null, color_name: 'Red', rgba: 'FF0000FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 100 },
+      { id: 2, material: 'PETG', brand: 'Bambu', tag_uid: 'CC:DD', archived_at: null, color_name: 'Blue', rgba: '0000FFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 200 },
+      { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
     ]),
     getPrinters: vi.fn().mockResolvedValue([]),
     getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
-    getSpoolmanSettings: vi.fn().mockResolvedValue({ spoolman_enabled: 'false', spoolman_url: '', spoolman_sync_mode: 'off', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'false' }),
-    getSpoolmanInventorySpools: vi.fn().mockResolvedValue([]),
-    getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
     linkTagToSpool: vi.fn().mockResolvedValue({}),
-    linkTagToSpoolmanSpool: vi.fn().mockResolvedValue({}),
     createSpool: vi.fn().mockResolvedValue({ id: 4 }),
-    createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 4 }),
     clearPlate: vi.fn().mockResolvedValue({}),
   },
   spoolbuddyApi: {
@@ -48,10 +37,9 @@ vi.mock('react-i18next', () => ({
   useTranslation: () => ({
     // Mirrors i18next's (key, defaultValue, options) signature with simple
     // {{var}} interpolation so tests can assert on the rendered text.
-    t: (key: string, fallback?: string, options?: Record<string, unknown>) => {
-      const text = fallback ?? key;
-      if (!options) return text;
-      return text.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(options[k] ?? ''));
+    t: (_key: string, fallback: string, options?: Record<string, unknown>) => {
+      if (!options) return fallback;
+      return fallback.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(options[k] ?? ''));
     },
     i18n: { language: 'en', changeLanguage: vi.fn() },
   }),
@@ -66,7 +54,6 @@ const mockOutletContext = {
     rawAdc: null,
     matchedSpool: null,
     unknownTagUid: null,
-    unknownTrayUuid: null,
     deviceOnline: true,
     deviceId: 'dev-1',
     remainingWeight: null,
@@ -258,333 +245,4 @@ describe('SpoolBuddyDashboard', () => {
       });
     });
   });
-
-  describe('Spoolman mode', () => {
-    it('fetches from getSpoolmanInventorySpools when Spoolman is enabled', async () => {
-      const { api } = await import('../../api/client');
-      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
-        spoolman_enabled: 'true',
-        spoolman_url: 'http://localhost:7912',
-        spoolman_sync_mode: 'off',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      });
-      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
-        { id: 10, material: 'PLA', brand: 'Bambu', tag_uid: 'SM:01', tray_uuid: null, archived_at: null, color_name: 'Green', rgba: '00FF00FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
-      ]);
-
-      renderPage();
-
-      await waitFor(() => {
-        expect(api.getSpoolmanInventorySpools).toHaveBeenCalled();
-      });
-    });
-
-    it('still uses getSpools when Spoolman is disabled', async () => {
-      const { api } = await import('../../api/client');
-      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
-        spoolman_enabled: 'false',
-        spoolman_url: '',
-        spoolman_sync_mode: 'off',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      });
-
-      renderPage();
-
-      await waitFor(() => {
-        expect(api.getSpools).toHaveBeenCalled();
-      });
-    });
-
-    it('excludes tray_uuid spools from the untagged list in Spoolman mode', async () => {
-      const { api } = await import('../../api/client');
-      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
-        spoolman_enabled: 'true',
-        spoolman_url: 'http://localhost:7912',
-        spoolman_sync_mode: 'off',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      });
-      // One spool has tray_uuid (linked via Bambu) → excluded from untagged
-      // One spool has neither tag_uid nor tray_uuid → included
-      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
-        { id: 20, material: 'PETG', brand: 'Bambu', tag_uid: null, tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF', archived_at: null, color_name: 'Blue', rgba: '0000FFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
-        { id: 21, material: 'ABS', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Black', rgba: '000000FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
-      ]);
-
-      renderPage({ unknownTagUid: 'AABB1122', unknownTrayUuid: 'CAFEBABECAFEBABECAFEBABECAFEBABE' });
-
-      // Open the link modal
-      const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
-      fireEvent.click(linkBtn);
-
-      await waitFor(() => {
-        // Only the ABS spool (id=21) should appear — the PETG with tray_uuid is excluded
-        expect(screen.getByText('Black')).toBeDefined();
-        expect(screen.queryByText('Blue')).toBeNull();
-      });
-    });
-
-    it('calls linkTagToSpoolmanSpool with tag_uid when linking in Spoolman mode', async () => {
-      const { api } = await import('../../api/client');
-      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
-        spoolman_enabled: 'true',
-        spoolman_url: 'http://localhost:7912',
-        spoolman_sync_mode: 'off',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      });
-      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
-        { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
-      ]);
-
-      renderPage({
-        unknownTagUid: 'AABB1122334455FF',
-        unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
-      });
-
-      const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
-      fireEvent.click(linkBtn);
-
-      const spoolBtn = await waitFor(() => screen.getByText('Orange'));
-      fireEvent.click(spoolBtn);
-
-      const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
-      fireEvent.click(confirmBtn);
-
-      await waitFor(() => {
-        expect(api.linkTagToSpoolmanSpool).toHaveBeenCalledWith(30, {
-          tag_uid: 'AABB1122334455FF',
-          tray_uuid: undefined,
-        });
-      });
-    });
-
-    it('switches to SpoolInfoCard and hides UnknownTagCard after successful Spoolman link', async () => {
-      const { api } = await import('../../api/client');
-      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
-        spoolman_enabled: 'true',
-        spoolman_url: 'http://localhost:7912',
-        spoolman_sync_mode: 'off',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      });
-      const linkedSpool = {
-        id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null,
-        tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF', archived_at: null,
-        color_name: 'Orange', rgba: 'FF6600FF', subtype: null,
-        label_weight: 1000, core_weight: 250, weight_used: 0,
-        created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
-      };
-      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
-        { ...linkedSpool, tag_uid: null, tray_uuid: null },
-      ]);
-      (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockResolvedValue(linkedSpool);
-
-      renderPage({
-        unknownTagUid: 'AABB1122334455FF',
-        unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
-      });
-
-      const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
-      fireEvent.click(linkBtn);
-
-      const spoolBtn = await waitFor(() => screen.getByText('Orange'));
-      fireEvent.click(spoolBtn);
-
-      const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
-      fireEvent.click(confirmBtn);
-
-      await waitFor(() => {
-        expect(screen.queryByText('Assign Spool')).toBeNull();
-        expect(screen.getByText('Sync Weight')).toBeDefined();
-        expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkSuccess', 'success');
-      });
-    });
-
-    it('shows error toast and closes modal when Spoolman link fails', async () => {
-      const { api } = await import('../../api/client');
-      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
-        spoolman_enabled: 'true',
-        spoolman_url: 'http://localhost:7912',
-        spoolman_sync_mode: 'off',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      });
-      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
-        { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
-      ]);
-      (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('503'));
-
-      renderPage({
-        unknownTagUid: 'AABB1122334455FF',
-        unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
-      });
-
-      const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
-      fireEvent.click(linkBtn);
-      const spoolBtn = await waitFor(() => screen.getByText('Orange'));
-      fireEvent.click(spoolBtn);
-      fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
-
-      await waitFor(() => {
-        // Error toast shown
-        expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkFailed', 'error');
-        // Modal closed via finally
-        expect(screen.queryByText('Link Tag')).toBeNull();
-        // UnknownTagCard still visible — no card switch on failure
-        expect(screen.getByText('Assign Spool')).toBeDefined();
-      });
-    });
-
-    it('clears justLinkedSpool and shows new UnknownTagCard when a different tag is placed', async () => {
-      const { api } = await import('../../api/client');
-      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
-        spoolman_enabled: 'true',
-        spoolman_url: 'http://localhost:7912',
-        spoolman_sync_mode: 'off',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      });
-      const linkedSpool = {
-        id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null,
-        tray_uuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF', archived_at: null,
-        color_name: 'Orange', rgba: 'FF6600FF', subtype: null,
-        label_weight: 1000, core_weight: 250, weight_used: 0,
-        created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
-      };
-      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
-        { ...linkedSpool, tag_uid: null, tray_uuid: null },
-        { id: 31, material: 'PLA', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Green', rgba: '00FF00FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
-      ]);
-      (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockResolvedValue(linkedSpool);
-
-      // Stateful wrapper so sbState can be updated mid-test. Stash the setter
-      // on a ref-shaped object instead of a bare `let` reassigned during
-      // render — react-hooks/globals (eslint-plugin-react-hooks v5) flags
-      // that as a side effect during render. Mutating a property on a
-      // pre-allocated object is fine because the object identity doesn't
-      // change.
-      const setterRef: { current: React.Dispatch<React.SetStateAction<typeof mockOutletContext.sbState>> | null } = { current: null };
-      function DynWrapper() {
-        const [sbState, setSbState] = React.useState({
-          ...mockOutletContext.sbState,
-          unknownTagUid: 'AABB1122334455FF',
-          unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
-        });
-        setterRef.current = setSbState;
-        return <Outlet context={{ ...mockOutletContext, sbState }} />;
-      }
-      const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
-      render(
-        <ToastProvider>
-          <QueryClientProvider client={qc}>
-            <MemoryRouter initialEntries={['/spoolbuddy']}>
-              <Routes>
-                <Route element={<DynWrapper />}>
-                  <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
-                </Route>
-              </Routes>
-            </MemoryRouter>
-          </QueryClientProvider>
-        </ToastProvider>
-      );
-
-      // Link spool — SpoolInfoCard appears via justLinkedSpool
-      fireEvent.click(await waitFor(() => screen.getByText('Assign Spool')));
-      fireEvent.click(await waitFor(() => screen.getByText('Orange')));
-      fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
-      await waitFor(() => expect(screen.getByText('Sync Weight')).toBeDefined());
-
-      // Different tag placed → justLinkedSpool cleared
-      act(() => setterRef.current!((prev) => ({ ...prev, unknownTagUid: 'CCDD5566', unknownTrayUuid: null })));
-
-      await waitFor(() => {
-        expect(screen.queryByText('Sync Weight')).toBeNull();
-        expect(screen.getByText('Assign Spool')).toBeDefined();
-      });
-    });
-
-    it('calls linkTagToSpool (local) when Spoolman is disabled — no regression', async () => {
-      const { api } = await import('../../api/client');
-      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
-        spoolman_enabled: 'false',
-        spoolman_url: '',
-        spoolman_sync_mode: 'off',
-        spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      });
-      (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([
-        { id: 3, material: 'ABS', brand: 'Polymaker', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'White', rgba: 'FFFFFFFF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
-      ]);
-
-      renderPage({ unknownTagUid: 'AABB9999' });
-
-      const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
-      fireEvent.click(linkBtn);
-
-      const spoolBtn = await waitFor(() => screen.getByText('White'));
-      fireEvent.click(spoolBtn);
-
-      const confirmBtn = await waitFor(() => screen.getByText('Link Tag'));
-      fireEvent.click(confirmBtn);
-
-      await waitFor(() => {
-        expect(api.linkTagToSpool).toHaveBeenCalledWith(3, {
-          tag_uid: 'AABB9999',
-          tag_type: 'generic',
-          data_origin: 'nfc_link',
-        });
-        expect(api.linkTagToSpoolmanSpool).not.toHaveBeenCalled();
-        // Local path never sets justLinkedSpool → no Spoolman success toast
-        expect(mockShowToast).not.toHaveBeenCalled();
-        // Modal closes via finally, UnknownTagCard still absent (tag still present but no SpoolInfoCard)
-        expect(screen.queryByText('Link Tag')).toBeNull();
-      });
-    });
-  });
-
-  describe('Spoolman mode', () => {
-    const SPOOLMAN_SPOOL = {
-      id: 42, material: 'PLA', subtype: null, brand: 'Bambu',
-      color_name: 'Red', rgba: 'FF0000FF', extra_colors: null, effect_type: null,
-      label_weight: 1000, core_weight: 250, core_weight_catalog_id: null,
-      weight_used: 200, tag_uid: 'AABB11223344', tray_uuid: null,
-      slicer_filament: null, slicer_filament_name: null, nozzle_temp_min: null,
-      nozzle_temp_max: null, note: null, added_full: null, last_used: null,
-      encode_time: null, data_origin: 'spoolman', tag_type: null, archived_at: null,
-      created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
-      cost_per_kg: null, last_scale_weight: null, last_weighed_at: null,
-      category: null, low_stock_threshold_pct: null, k_profiles: [], storage_location: null,
-    };
-
-    it('disables "Assign to AMS" button when spool is already assigned', async () => {
-      const { api } = await import('../../api/client');
-      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
-        spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912',
-        spoolman_sync_mode: 'off', spoolman_disable_weight_sync: 'false',
-        spoolman_report_partial_usage: 'false',
-      } as never);
-      vi.mocked(api.getSpoolmanInventorySpools).mockResolvedValue([SPOOLMAN_SPOOL] as never);
-      vi.mocked(api.getSpoolmanSlotAssignments).mockResolvedValue([
-        { printer_id: 1, ams_id: 0, tray_id: 0, spoolman_spool_id: 42 },
-      ] as never);
-
-      renderPage({
-        deviceOnline: true,
-        matchedSpool: {
-          id: 42, tag_uid: 'AABB11223344', material: 'PLA', subtype: null,
-          color_name: 'Red', rgba: 'FF0000FF', brand: 'Bambu',
-          label_weight: 1000, core_weight: 250, weight_used: 200,
-        },
-      });
-
-      await waitFor(() => {
-        const btn = screen.queryByText('Assign to AMS');
-        expect(btn).toBeTruthy();
-        expect(btn).toBeDisabled();
-      });
-    });
-  });
 });

+ 7 - 56
frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx

@@ -13,9 +13,7 @@ import React from 'react';
 import { render } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
-import { ToastProvider } from '../../contexts/ToastContext';
 import { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage';
-import { api as mockedApi, spoolbuddyApi as mockedSpoolbuddyApi } from '../../api/client';
 
 // Mock the API modules
 vi.mock('../../api/client', () => ({
@@ -70,15 +68,13 @@ function renderPage() {
 
   return render(
     <QueryClientProvider client={queryClient}>
-      <ToastProvider>
-        <MemoryRouter initialEntries={['/spoolbuddy/write-tag']}>
-          <Routes>
-            <Route element={<OutletWrapper />}>
-              <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
-            </Route>
-          </Routes>
-        </MemoryRouter>
-      </ToastProvider>
+      <MemoryRouter initialEntries={['/spoolbuddy/write-tag']}>
+        <Routes>
+          <Route element={<OutletWrapper />}>
+            <Route path="spoolbuddy/write-tag" element={<SpoolBuddyWriteTagPage />} />
+          </Route>
+        </Routes>
+      </MemoryRouter>
     </QueryClientProvider>
   );
 }
@@ -138,49 +134,4 @@ describe('SpoolBuddyWriteTagPage', () => {
     expect(screen.getByText('Select a spool, then place a blank NTAG on the reader')).toBeDefined();
     mockOutletContext.sbState.deviceOnline = false; // reset
   });
-
-  it('shows one toast per warning when writeTag returns multiple warnings (B5 / T2)', async () => {
-    const spool = {
-      id: 99,
-      material: 'PLA',
-      label_weight: 1000,
-      weight_used: 0,
-      tag_uid: null,
-      tray_uuid: null,
-      archived_at: null,
-      rgba: 'FF0000FF',
-    };
-    vi.mocked(mockedApi.getSpools).mockResolvedValue([spool as never]);
-    vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([
-      { device_id: 'sb-test', hostname: 'sb-test.local' } as never,
-    ]);
-    vi.mocked(mockedSpoolbuddyApi.writeTag).mockResolvedValueOnce({
-      status: 'queued',
-      warnings: ['color_name not set', 'nozzle_temp_min not set'],
-    } as never);
-
-    mockOutletContext.sbState.deviceOnline = true;
-    renderPage();
-
-    // Wait for the spool to appear and click it to select it
-    await waitFor(() => {
-      expect(screen.getByText('PLA')).toBeDefined();
-    });
-    fireEvent.click(screen.getByText('PLA'));
-
-    // Click the Write Tag button
-    await waitFor(() => {
-      const writeBtn = screen.getByText('Write Tag');
-      expect((writeBtn as HTMLButtonElement).disabled).toBe(false);
-    });
-    fireEvent.click(screen.getByText('Write Tag'));
-
-    // Both warning messages must appear as separate toasts
-    await waitFor(() => {
-      expect(screen.getByText('color_name not set')).toBeDefined();
-      expect(screen.getByText('nozzle_temp_min not set')).toBeDefined();
-    });
-
-    mockOutletContext.sbState.deviceOnline = false; // reset
-  });
 });

+ 0 - 110
frontend/src/__tests__/utils/inventorySearch.test.ts

@@ -1,110 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { spoolMatchesQuery, filterSpoolsByQuery } from '../../utils/inventorySearch';
-import type { InventorySpool } from '../../api/client';
-
-function makeSpool(overrides: Partial<InventorySpool> = {}): InventorySpool {
-  return {
-    id: 1,
-    material: 'PLA',
-    subtype: 'Basic',
-    color_name: 'Red',
-    rgba: 'FF0000FF',
-    brand: 'Bambu Lab',
-    label_weight: 1000,
-    core_weight: 250,
-    core_weight_catalog_id: null,
-    weight_used: 0,
-    slicer_filament: null,
-    slicer_filament_name: null,
-    nozzle_temp_min: 220,
-    nozzle_temp_max: 240,
-    note: null,
-    added_full: null,
-    last_used: null,
-    encode_time: null,
-    tag_uid: null,
-    tray_uuid: null,
-    data_origin: 'local',
-    tag_type: null,
-    archived_at: null,
-    created_at: '2024-01-01T00:00:00Z',
-    updated_at: '2024-01-01T00:00:00Z',
-    cost_per_kg: null,
-    last_scale_weight: null,
-    last_weighed_at: null,
-    category: null,
-    low_stock_threshold_pct: null,
-    ...overrides,
-  };
-}
-
-describe('spoolMatchesQuery', () => {
-  it('returns true for empty query', () => {
-    expect(spoolMatchesQuery(makeSpool(), '')).toBe(true);
-  });
-
-  it('matches on material (case-insensitive)', () => {
-    const spool = makeSpool({ material: 'PETG' });
-    expect(spoolMatchesQuery(spool, 'petg')).toBe(true);
-    expect(spoolMatchesQuery(spool, 'PET')).toBe(true);
-    expect(spoolMatchesQuery(spool, 'pla')).toBe(false);
-  });
-
-  it('matches on brand (case-insensitive)', () => {
-    const spool = makeSpool({ brand: 'Prusament' });
-    expect(spoolMatchesQuery(spool, 'prusa')).toBe(true);
-    expect(spoolMatchesQuery(spool, 'PRUSA')).toBe(true);
-  });
-
-  it('matches on color_name (case-insensitive)', () => {
-    const spool = makeSpool({ color_name: 'Galaxy Black' });
-    expect(spoolMatchesQuery(spool, 'galaxy')).toBe(true);
-    expect(spoolMatchesQuery(spool, 'GALAXY')).toBe(true);
-    expect(spoolMatchesQuery(spool, 'blue')).toBe(false);
-  });
-
-  it('matches on subtype (case-insensitive)', () => {
-    const spool = makeSpool({ subtype: 'Matte' });
-    expect(spoolMatchesQuery(spool, 'matt')).toBe(true);
-    expect(spoolMatchesQuery(spool, 'MATTE')).toBe(true);
-  });
-
-  it('returns false when null optional fields do not match', () => {
-    const spool = makeSpool({ brand: null, color_name: null, subtype: null });
-    expect(spoolMatchesQuery(spool, 'bambu')).toBe(false);
-  });
-});
-
-describe('filterSpoolsByQuery', () => {
-  const spools = [
-    makeSpool({ id: 1, material: 'PLA', brand: 'Bambu Lab', color_name: 'Red' }),
-    makeSpool({ id: 2, material: 'PETG', brand: 'Prusament', color_name: 'Blue' }),
-    makeSpool({ id: 3, material: 'ABS', brand: null, color_name: 'Black', subtype: 'Matte' }),
-  ];
-
-  it('returns all spools for empty query', () => {
-    expect(filterSpoolsByQuery(spools, '')).toHaveLength(3);
-  });
-
-  it('filters by material', () => {
-    const result = filterSpoolsByQuery(spools, 'pla');
-    expect(result).toHaveLength(1);
-    expect(result[0].id).toBe(1);
-  });
-
-  it('filters by brand', () => {
-    const result = filterSpoolsByQuery(spools, 'prusa');
-    expect(result).toHaveLength(1);
-    expect(result[0].id).toBe(2);
-  });
-
-  it('filters by subtype', () => {
-    const result = filterSpoolsByQuery(spools, 'matte');
-    expect(result).toHaveLength(1);
-    expect(result[0].id).toBe(3);
-  });
-
-  it('returns empty array when no match', () => {
-    expect(filterSpoolsByQuery(spools, 'nylon')).toHaveLength(0);
-  });
-});

+ 0 - 65
frontend/src/__tests__/utils/isBambuLabSpool.test.ts

@@ -1,65 +0,0 @@
-/**
- * Tests for isBambuLabSpool helper.
- *
- * The function is permissive: any non-empty non-zero value of tray_uuid OR
- * tag_uid returns true. It does NOT validate hex-length or character set —
- * its job is solely to suppress assign/unassign actions on RFID-managed slots
- * whose state is owned by the printer firmware.
- */
-
-import { describe, it, expect } from 'vitest';
-
-import { isBambuLabSpool } from '../../utils/amsHelpers';
-
-describe('isBambuLabSpool', () => {
-  it('returns false for null', () => {
-    expect(isBambuLabSpool(null)).toBe(false);
-  });
-
-  it('returns false for undefined', () => {
-    expect(isBambuLabSpool(undefined)).toBe(false);
-  });
-
-  it('returns false for an empty object', () => {
-    expect(isBambuLabSpool({})).toBe(false);
-  });
-
-  it('returns true for a valid 32-hex non-zero tray_uuid', () => {
-    expect(
-      isBambuLabSpool({ tray_uuid: '11223344556677880011223344556677' }),
-    ).toBe(true);
-  });
-
-  it('returns false for the zero-string 32-char tray_uuid', () => {
-    expect(
-      isBambuLabSpool({ tray_uuid: '00000000000000000000000000000000' }),
-    ).toBe(false);
-  });
-
-  it('returns true for a valid 16-hex non-zero tag_uid', () => {
-    expect(isBambuLabSpool({ tag_uid: 'AABBCC1122334400' })).toBe(true);
-  });
-
-  it('returns false for the zero-string 16-char tag_uid', () => {
-    expect(isBambuLabSpool({ tag_uid: '0000000000000000' })).toBe(false);
-  });
-
-  it('returns false when both fields are explicitly null', () => {
-    expect(isBambuLabSpool({ tray_uuid: null, tag_uid: null })).toBe(false);
-  });
-
-  it('returns true when only tag_uid is set and tray_uuid is null', () => {
-    expect(
-      isBambuLabSpool({ tray_uuid: null, tag_uid: 'AABBCC1122334400' }),
-    ).toBe(true);
-  });
-
-  it('returns true when only tray_uuid is set and tag_uid is null', () => {
-    expect(
-      isBambuLabSpool({
-        tray_uuid: '11223344556677880011223344556677',
-        tag_uid: null,
-      }),
-    ).toBe(true);
-  });
-});

+ 0 - 96
frontend/src/__tests__/utils/spoolFormValidation.test.ts

@@ -1,96 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { validateForm, defaultFormData } from '../../components/spool-form/types';
-
-describe('validateForm', () => {
-  describe('standard mode', () => {
-    it('requires slicer_filament, material, brand, and subtype', () => {
-      const result = validateForm(defaultFormData);
-      expect(result.isValid).toBe(false);
-      expect(result.errors.slicer_filament).toBeDefined();
-      expect(result.errors.material).toBeDefined();
-      expect(result.errors.brand).toBeDefined();
-      expect(result.errors.subtype).toBeDefined();
-    });
-
-    it('passes when all required fields are filled', () => {
-      const data = {
-        ...defaultFormData,
-        slicer_filament: 'Bambu PLA Basic @BBL',
-        material: 'PLA',
-        brand: 'Bambu Lab',
-        subtype: 'Basic',
-      };
-      const result = validateForm(data);
-      expect(result.isValid).toBe(true);
-      expect(Object.keys(result.errors)).toHaveLength(0);
-    });
-  });
-
-  describe('quickAdd mode', () => {
-    it('only requires material', () => {
-      const result = validateForm(defaultFormData, true);
-      expect(result.isValid).toBe(false);
-      expect(result.errors.material).toBeDefined();
-      expect(result.errors.slicer_filament).toBeUndefined();
-      expect(result.errors.brand).toBeUndefined();
-    });
-
-    it('passes with only material set', () => {
-      const data = { ...defaultFormData, material: 'PETG' };
-      const result = validateForm(data, true);
-      expect(result.isValid).toBe(true);
-    });
-  });
-
-  describe('spoolmanMode', () => {
-    it('only requires material (same as quickAdd)', () => {
-      const result = validateForm(defaultFormData, false, true);
-      expect(result.isValid).toBe(false);
-      expect(result.errors.material).toBeDefined();
-      expect(result.errors.slicer_filament).toBeUndefined();
-      expect(result.errors.brand).toBeUndefined();
-      expect(result.errors.subtype).toBeUndefined();
-    });
-
-    it('passes with only material set', () => {
-      const data = { ...defaultFormData, material: 'PLA' };
-      const result = validateForm(data, false, true);
-      expect(result.isValid).toBe(true);
-      expect(Object.keys(result.errors)).toHaveLength(0);
-    });
-
-    it('does not require slicer_filament even when present', () => {
-      const data = { ...defaultFormData, material: 'ABS' };
-      const result = validateForm(data, false, true);
-      expect(result.isValid).toBe(true);
-    });
-
-    it('fails when material is empty string', () => {
-      const data = { ...defaultFormData, material: '' };
-      const result = validateForm(data, false, true);
-      expect(result.isValid).toBe(false);
-      expect(result.errors.material).toBeDefined();
-    });
-
-    it('quickAdd takes precedence over spoolmanMode', () => {
-      const data = { ...defaultFormData, material: 'PLA' };
-      const result = validateForm(data, true, true);
-      expect(result.isValid).toBe(true);
-    });
-
-    it('passes when spoolman_filament_id is set and material is empty', () => {
-      // When a catalog entry is pre-selected, material is not required
-      const data = { ...defaultFormData, material: '', spoolman_filament_id: 7 };
-      const result = validateForm(data, false, true);
-      expect(result.isValid).toBe(true);
-      expect(result.errors.material).toBeUndefined();
-    });
-
-    it('fails when both material and spoolman_filament_id are absent', () => {
-      const data = { ...defaultFormData, material: '', spoolman_filament_id: null };
-      const result = validateForm(data, false, true);
-      expect(result.isValid).toBe(false);
-      expect(result.errors.material).toBeDefined();
-    });
-  });
-});

+ 2 - 127
frontend/src/api/client.ts

@@ -2,19 +2,6 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
 
 const API_BASE = '/api/v1';
 
-export class ApiError extends Error {
-  status: number;
-  constructor(message: string, status: number) {
-    super(message);
-    this.name = 'ApiError';
-    this.status = status;
-  }
-}
-
-// Auth token storage
-// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
-// When the token originates from the ?token= URL param (kiosk bootstrap), it is
-// additionally persisted in localStorage so the kiosk survives page reloads.
 // 'persistent' also writes to localStorage so the token survives tab close
 // (used by Remember Me and the ?token= kiosk bootstrap).
 let authToken: string | null =
@@ -125,7 +112,7 @@ async function request<T>(
       }
     }
 
-    throw new ApiError(message, response.status);
+    throw new Error(message);
   }
 
   // Handle empty responses (204 No Content, etc.)
@@ -2308,22 +2295,6 @@ export interface LinkedSpoolsMap {
   linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
 }
 
-export interface SpoolmanVendor {
-  id: number;
-  name: string;
-}
-
-export interface SpoolmanFilamentEntry {
-  id: number;
-  name: string;
-  material: string | null;
-  color_hex: string | null;
-  color_name: string | null;
-  weight: number | null;
-  spool_weight: number | null;
-  vendor: SpoolmanVendor | null;
-}
-
 // Inventory types
 // Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
 export type SpoolLabelTemplate = 'ams_30x15' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
@@ -2365,13 +2336,6 @@ export interface InventorySpool {
   category: string | null;
   low_stock_threshold_pct: number | null;
   k_profiles?: SpoolKProfile[];
-  storage_location?: string | null;
-}
-
-export interface SpoolmanBulkCreateResult {
-  created: InventorySpool[];
-  requested_count: number;
-  failed_count: number;
 }
 
 export interface SpoolUsageRecord {
@@ -4434,19 +4398,8 @@ export const api = {
     }),
   getSpoolmanSpools: () =>
     request<{ spools: unknown[] }>('/spoolman/spools'),
-  /** @deprecated Use getSpoolmanInventoryFilaments() — this endpoint has no SSRF guard */
   getSpoolmanFilaments: () =>
     request<{ filaments: unknown[] }>('/spoolman/filaments'),
-  getSpoolmanInventoryFilaments: () =>
-    request<SpoolmanFilamentEntry[]>('/spoolman/inventory/filaments'),
-  patchSpoolmanFilament: (
-    filamentId: number,
-    data: { name?: string; spool_weight?: number | null; keep_existing_spools?: boolean },
-  ) =>
-    request<SpoolmanFilamentEntry>(`/spoolman/inventory/filaments/${filamentId}`, {
-      method: 'PATCH',
-      body: JSON.stringify(data),
-    }),
   getUnlinkedSpools: () =>
     request<UnlinkedSpool[]>('/spoolman/spools/unlinked'),
   getLinkedSpools: () =>
@@ -4638,84 +4591,6 @@ export const api = {
   getFilamentPresets: () =>
     request<SlicerSetting[]>('/cloud/filaments'),
 
-  // Spoolman Inventory proxy (unified UI when Spoolman is enabled)
-  getSpoolmanInventorySpools: (includeArchived = false) =>
-    request<InventorySpool[]>(`/spoolman/inventory/spools?include_archived=${includeArchived}`),
-  getSpoolmanInventorySpool: (id: number) =>
-    request<InventorySpool>(`/spoolman/inventory/spools/${id}`),
-  createSpoolmanInventorySpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>
-    request<InventorySpool>('/spoolman/inventory/spools', {
-      method: 'POST',
-      body: JSON.stringify(data),
-    }),
-  bulkCreateSpoolmanInventorySpools: (
-    data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>,
-    quantity: number,
-  ) =>
-    request<SpoolmanBulkCreateResult | InventorySpool[]>('/spoolman/inventory/spools/bulk', {
-      method: 'POST',
-      body: JSON.stringify({ spool: data, quantity }),
-    }),
-  updateSpoolmanInventorySpool: (
-    id: number,
-    data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>,
-  ) =>
-    request<InventorySpool>(`/spoolman/inventory/spools/${id}`, {
-      method: 'PATCH',
-      body: JSON.stringify(data),
-    }),
-  deleteSpoolmanInventorySpool: (id: number) =>
-    request<{ status: string }>(`/spoolman/inventory/spools/${id}`, { method: 'DELETE' }),
-  archiveSpoolmanInventorySpool: (id: number) =>
-    request<InventorySpool>(`/spoolman/inventory/spools/${id}/archive`, { method: 'POST' }),
-  restoreSpoolmanInventorySpool: (id: number) =>
-    request<InventorySpool>(`/spoolman/inventory/spools/${id}/restore`, { method: 'POST' }),
-  linkTagToSpoolmanSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string }) =>
-    request<InventorySpool>(`/spoolman/inventory/spools/${spoolId}/tag`, {
-      method: 'PATCH',
-      body: JSON.stringify(data),
-    }),
-  syncSpoolmanSpoolWeight: (spoolId: number, weightGrams: number) =>
-    request<{ status: string; weight_used: number }>(`/spoolman/inventory/spools/${spoolId}/weight`, {
-      method: 'PATCH',
-      body: JSON.stringify({ weight_grams: weightGrams }),
-    }),
-  assignSpoolmanSlot: (data: { spoolman_spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
-    request<InventorySpool>('/spoolman/inventory/slot-assignments', {
-      method: 'POST',
-      body: JSON.stringify(data),
-    }),
-  unassignSpoolmanSlot: (spoolmanSpoolId: number) =>
-    request<InventorySpool>(`/spoolman/inventory/slot-assignments/${spoolmanSpoolId}`, { method: 'DELETE' }),
-  getSpoolmanSlotAssignment: (printerId: number, amsId: number, trayId: number) =>
-    request<InventorySpool | null>(
-      `/spoolman/inventory/slot-assignments?printer_id=${printerId}&ams_id=${amsId}&tray_id=${trayId}`,
-    ),
-  getSpoolmanSlotAssignments: (printerId?: number) =>
-    request<Array<{
-      printer_id: number;
-      printer_name: string | null;
-      ams_id: number;
-      tray_id: number;
-      spoolman_spool_id: number;
-      ams_label: string | null;
-    }>>(
-      printerId !== undefined
-        ? `/spoolman/inventory/slot-assignments/all?printer_id=${printerId}`
-        : '/spoolman/inventory/slot-assignments/all',
-    ),
-  syncSpoolmanAmsWeights: () =>
-    request<{ synced: number; skipped: number }>('/spoolman/inventory/sync-ams-weights', { method: 'POST' }),
-
-  getSpoolmanKProfiles: (spoolId: number) =>
-    request<SpoolKProfile[]>(`/spoolman/inventory/spools/${spoolId}/k-profiles`),
-
-  saveSpoolmanKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
-    request<SpoolKProfile[]>(`/spoolman/inventory/spools/${spoolId}/k-profiles`, {
-      method: 'PUT',
-      body: JSON.stringify(profiles),
-    }),
-
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),
   checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
@@ -6385,7 +6260,7 @@ export const spoolbuddyApi = {
     request<{ public_key: string }>('/spoolbuddy/ssh/public-key'),
 
   writeTag: (deviceId: string, spoolId: number) =>
-    request<{ status: string; warnings?: string[] }>('/spoolbuddy/nfc/write-tag', {
+    request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
       method: 'POST',
       body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }),
     }),

+ 17 - 117
frontend/src/components/AssignSpoolModal.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Package, Search } from 'lucide-react';
@@ -7,7 +7,6 @@ import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
-import { filterSpoolsByQuery } from '../utils/inventorySearch';
 
 interface AssignSpoolModalProps {
   isOpen: boolean;
@@ -22,19 +21,16 @@ interface AssignSpoolModalProps {
     color: string;
     location: string;
   };
-  spoolmanEnabled?: boolean;
 }
 
-export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo, spoolmanEnabled }: AssignSpoolModalProps) {
+export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo }: AssignSpoolModalProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [disableFiltering, setDisableFiltering] = useState(false);
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
-  const [selectedSpoolmanSpoolId, setSelectedSpoolmanSpoolId] = useState<number | null>(null);
   useEffect(() => {
     setSelectedSpoolId(null);
-    setSelectedSpoolmanSpoolId(null);
   }, [disableFiltering]);
   const [searchFilter, setSearchFilter] = useState('');
   const [pendingAssignId, setPendingAssignId] = useState<number | null>(null);
@@ -69,7 +65,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   const { data: spools, isLoading } = useQuery({
     queryKey: ['inventory-spools', 'assign-modal'],
     queryFn: () => api.getSpools(true),
-    enabled: isOpen && !spoolmanEnabled,
+    enabled: isOpen,
   });
 
   const { data: assignments } = useQuery({
@@ -84,35 +80,6 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     enabled: isOpen,
   });
 
-  const { data: spoolmanSpools, isLoading: spoolmanLoading } = useQuery({
-    queryKey: ['spoolman-inventory-spools', 'assign-modal'],
-    queryFn: () => api.getSpoolmanInventorySpools(false),
-    enabled: isOpen && !!spoolmanEnabled,
-  });
-
-  // Spoolman SlotAssignments across all printers — used to filter out spools
-  // already bound to another slot. Without this filter the modal offers spools
-  // that are already in use elsewhere (e.g. an h2d-1 slot's spool appearing
-  // in the x1c-2 assign list), and assigning would silently steal it from
-  // the other printer's slot.
-  const { data: allSpoolmanAssignments } = useQuery({
-    queryKey: ['spoolman-slot-assignments-all'],
-    queryFn: () => api.getSpoolmanSlotAssignments(),
-    enabled: isOpen && !!spoolmanEnabled,
-  });
-
-  // ids of spools already in some Spoolman slot — excluding the current slot
-  // (so a user could in theory re-pick the same spool, though the modal is
-  // typically only opened from empty slots).
-  const assignedSpoolmanSpoolIds = useMemo(() => {
-    if (!allSpoolmanAssignments) return new Set<number>();
-    return new Set(
-      allSpoolmanAssignments
-        .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
-        .map(a => a.spoolman_spool_id),
-    );
-  }, [allSpoolmanAssignments, printerId, amsId, trayId]);
-
   const assignMutation = useMutation({
     mutationFn: (spoolId: number) =>
       api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
@@ -137,25 +104,6 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     },
   });
 
-  const assignSpoolmanMutation = useMutation({
-    mutationFn: (spoolmanSpoolId: number) =>
-      api.assignSpoolmanSlot({
-        spoolman_spool_id: spoolmanSpoolId,
-        printer_id: printerId,
-        ams_id: amsId,
-        tray_id: trayId,
-      }),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
-      showToast(t('inventory.assignSuccess'), 'success');
-      onClose();
-    },
-    onError: (error: Error) => {
-      showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
-    },
-  });
-
   // --- Material/profile mismatch logic ---
   const normalizeValue = (value: string | undefined | null) =>
     (value ?? '').trim().toUpperCase();
@@ -252,14 +200,18 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     }
   }
   if (searchFilter && filteredSpools) {
-    filteredSpools = filterSpoolsByQuery(filteredSpools, searchFilter);
+    const q = searchFilter.toLowerCase();
+    filteredSpools = filteredSpools.filter((spool: InventorySpool) => {
+      return (
+        spool.material.toLowerCase().includes(q) ||
+        (spool.brand?.toLowerCase().includes(q) ?? false) ||
+        (spool.color_name?.toLowerCase().includes(q) ?? false) ||
+        (spool.subtype?.toLowerCase().includes(q) ?? false)
+      );
+    });
   }
 
   const handleAssign = () => {
-    if (selectedSpoolmanSpoolId !== null) {
-      assignSpoolmanMutation.mutate(selectedSpoolmanSpoolId);
-      return;
-    }
     if (!selectedSpoolId) return;
     const selectedSpool = spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId);
     if (!selectedSpool) {
@@ -365,8 +317,8 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
           </div>
 
           {/* Spool list */}
-          <div className="space-y-3">
-            {!spoolmanEnabled && (isLoading ? (
+          <div>
+            {isLoading ? (
               <div className="flex justify-center py-8">
                 <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
               </div>
@@ -375,7 +327,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                 {filteredSpools.map((spool: InventorySpool) => (
                   <button
                     key={spool.id}
-                    onClick={() => { setSelectedSpoolId(spool.id); setSelectedSpoolmanSpoolId(null); }}
+                    onClick={() => setSelectedSpoolId(spool.id)}
                     title={spool.note || undefined}
                     className={`p-2.5 rounded-lg border text-left transition-colors ${
                       selectedSpoolId === spool.id
@@ -429,58 +381,6 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                   </p>
                 )}
               </div>
-            ))}
-
-            {spoolmanEnabled && (
-              <>
-                {spoolmanLoading ? (
-                  <div className="flex justify-center py-4">
-                    <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />
-                  </div>
-                ) : spoolmanSpools && spoolmanSpools.filter(s => !s.archived_at && !assignedSpoolmanSpoolIds.has(s.id)).length > 0 ? (
-                  <>
-                    <p className="text-xs font-medium text-bambu-gray uppercase tracking-wide pt-1">
-                      {t('inventory.spoolmanSpools')}
-                    </p>
-                    <div className="max-h-64 overflow-y-auto grid grid-cols-2 sm:grid-cols-3 gap-2">
-                      {filterSpoolsByQuery(spoolmanSpools.filter(s => !s.archived_at && !assignedSpoolmanSpoolIds.has(s.id)), searchFilter)
-                        .map((spool: InventorySpool) => (
-                          <button
-                            key={`spoolman-${spool.id}`}
-                            onClick={() => {
-                              setSelectedSpoolmanSpoolId(spool.id);
-                              setSelectedSpoolId(null);
-                            }}
-                            title={spool.note || undefined}
-                            className={`p-2.5 rounded-lg border text-left transition-colors ${
-                              selectedSpoolmanSpoolId === spool.id
-                                ? 'bg-bambu-green/20 border-bambu-green'
-                                : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
-                            }`}
-                          >
-                            <p className="text-white text-sm font-medium truncate">
-                              {spool.brand ? `${spool.brand} ` : ''}{spool.material}{spool.subtype ? ` ${spool.subtype}` : ''}
-                            </p>
-                            <div className="flex items-center gap-1.5 mt-1">
-                              {spool.rgba && (
-                                <span
-                                  className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
-                                  style={{ backgroundColor: `#${spool.rgba.substring(0, 6)}` }}
-                                />
-                              )}
-                              <span className="text-xs text-bambu-gray truncate">{spool.color_name || ''}</span>
-                            </div>
-                            {spool.label_weight && (
-                              <p className="text-xs text-bambu-gray mt-1">
-                                {Math.max(0, Math.round(spool.label_weight - spool.weight_used))} / {spool.label_weight}g
-                              </p>
-                            )}
-                          </button>
-                        ))}
-                    </div>
-                  </>
-                ) : null}
-              </>
             )}
           </div>
         </div>
@@ -505,9 +405,9 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
             </Button>
             <Button
               onClick={handleAssign}
-              disabled={(!selectedSpoolId && selectedSpoolmanSpoolId === null) || assignMutation.isPending || assignSpoolmanMutation.isPending}
+              disabled={!selectedSpoolId || assignMutation.isPending}
             >
-              {(assignMutation.isPending || assignSpoolmanMutation.isPending) ? (
+              {assignMutation.isPending ? (
                 <>
                   <Loader2 className="w-4 h-4 animate-spin" />
                   {t('inventory.assigning')}

+ 77 - 70
frontend/src/components/FilamentHoverCard.tsx

@@ -1,7 +1,6 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
-import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
-import { Droplets, Copy, Check, Settings2, Package, Unlink } from 'lucide-react';
+import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react';
 import { isLightColor } from '../utils/colors';
 
 interface FilamentData {
@@ -29,7 +28,6 @@ interface InventoryConfig {
   onAssignSpool?: () => void;
   onUnassignSpool?: () => void;
   assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null; remainingWeightGrams?: number | null } | null;
-  isAssigned?: boolean;
 }
 
 interface ConfigureSlotConfig {
@@ -53,7 +51,6 @@ interface FilamentHoverCardProps {
  */
 export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) {
   const { t } = useTranslation();
-  const navigate = useNavigate();
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [copied, setCopied] = useState(false);
@@ -239,6 +236,12 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     {assignedRemainingWeight !== null && data.fillLevel !== null && (
                       <span className="text-[9px] text-bambu-gray font-normal">• {assignedRemainingWeight}g</span>
                     )}
+                    {data.fillSource === 'spoolman' && data.fillLevel !== null && (
+                      <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
+                    )}
+                    {data.fillSource === 'inventory' && data.fillLevel !== null && (
+                      <span className="text-[9px] text-bambu-gray font-normal">{t('inventory.fillSourceLabel')}</span>
+                    )}
                   </span>
                 </div>
                 {/* Fill bar */}
@@ -288,34 +291,57 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     )}
                   </div>
 
-                  {/* Open in inventory button (when already linked to a Spoolman spool) */}
-                  {spoolman.linkedSpoolId && (
+                  {/* Open in Spoolman button (when already linked) */}
+                  {spoolman.linkedSpoolId && spoolman.spoolmanUrl && (
                     <>
-                      <button
-                        onClick={(e) => {
-                          e.stopPropagation();
-                          navigate(`/inventory?spool=${spoolman.linkedSpoolId}`);
-                        }}
+                      <a
+                        href={`${spoolman.spoolmanUrl.replace(/\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        onClick={(e) => e.stopPropagation()}
                         className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
-                        title={t('inventory.openInInventory')}
+                        title={t('spoolman.openInSpoolman')}
                       >
-                        <Package className="w-3.5 h-3.5" />
-                        {t('inventory.openInInventory')}
-                      </button>
+                        <ExternalLink className="w-3.5 h-3.5" />
+                        {t('spoolman.openInSpoolman')}
+                      </a>
 
+                      {spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && (
+                        <button
+                          onClick={(e) => {
+                            e.stopPropagation();
+                            setShowUnlinkConfirm(true);
+                          }}
+                          className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400"
+                          title={t('spoolman.unlinkSpool')}
+                        >
+                          <Unlink className="w-3.5 h-3.5" />
+                          {t('spoolman.unlinkSpool')}
+                        </button>
+                      )}
                     </>
                   )}
 
-                  {/* Link/Unlink action buttons intentionally NOT rendered
-                      here. The inventory section below already provides
-                      Assign/Unassign for slot-binding (the primary user
-                      flow in Spoolman mode). Showing the spoolman tag-link
-                      buttons in addition surfaced two red Unlink-icon
-                      buttons for what users perceive as the same action,
-                      regardless of whether the labels said "Unlink Spool"
-                      vs "Unassign Spool". Tag-linking remains available
-                      via dedicated UI (LinkSpoolModal can be opened from
-                      Spoolman settings / inventory page). */}
+                  {/* Link Spool button (when not linked) */}
+                  {!spoolman.linkedSpoolId && (
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        if (spoolman.onLinkSpool) {
+                          spoolman.onLinkSpool?.();
+                        }
+                      }}
+                      disabled={!spoolman.onLinkSpool}
+                      className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors ${
+                        !spoolman.onLinkSpool
+                          ? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'
+                          : 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'
+                      }`}
+                    >
+                      <Link2 className="w-3.5 h-3.5" />
+                      {t('spoolman.linkToSpoolman')}
+                    </button>
+                  )}
                 </div>
               )}
 
@@ -339,19 +365,6 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                         {inventory.assignedSpool.material}
                         {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
                       </p>
-                      {(!spoolman?.linkedSpoolId || inventory.assignedSpool!.id !== spoolman.linkedSpoolId) && (
-                        <button
-                          onClick={(e) => {
-                            e.stopPropagation();
-                            navigate(`/inventory?spool=${inventory.assignedSpool!.id}`);
-                          }}
-                          className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
-                          title={t('inventory.openInInventory')}
-                        >
-                          <Package className="w-3.5 h-3.5" />
-                          {t('inventory.openInInventory')}
-                        </button>
-                      )}
                       {inventory.onUnassignSpool && (
                         <button
                           onClick={(e) => {
@@ -367,14 +380,11 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     </>
                   ) : inventory.onAssignSpool ? (
                     <button
-                      onClick={inventory.isAssigned ? undefined : (e) => {
+                      onClick={(e) => {
                         e.stopPropagation();
                         inventory.onAssignSpool?.();
                       }}
-                      disabled={!!inventory.isAssigned}
-                      className={`w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 text-bambu-blue ${
-                        inventory.isAssigned ? 'opacity-50 cursor-not-allowed' : 'hover:bg-bambu-blue/30'
-                      }`}
+                      className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
                     >
                       <Package className="w-3.5 h-3.5" />
                       {t('inventory.assignSpool')}
@@ -447,7 +457,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   }}
                   className="flex-1 px-3 py-2 text-sm font-medium rounded transition-colors bg-red-500/20 hover:bg-red-500/30 text-red-400"
                 >
-                  {t('inventory.unassignSpool')}
+                  {t('spoolman.unlinkSpool')}
                 </button>
               </div>
             </div>
@@ -462,10 +472,18 @@ interface EmptySlotHoverCardProps {
   children: ReactNode;
   className?: string;
   configureSlot?: ConfigureSlotConfig;
-  onAssignSpool?: () => void;
 }
 
-export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool }: EmptySlotHoverCardProps) {
+/**
+ * Wrapper for empty slots - shows "Empty" on hover with optional configure button.
+ *
+ * The "Assign spool" affordance was removed from empty slots in #1133: a
+ * physically empty slot has no spool to attach to, and offering the
+ * action there only led to users assigning the wrong spool to a slot
+ * the printer hadn't actually loaded yet. Assignment now requires a
+ * loaded slot (which renders FilamentHoverCard, where the button lives).
+ */
+export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
   const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -507,30 +525,19 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
               {t('ams.emptySlot')}
             </div>
             {/* Configure slot button */}
-            {(configureSlot?.enabled || onAssignSpool) && (
-              <div className="px-2 pb-2 space-y-1">
-                {configureSlot?.enabled && (
-                  <button
-                    onClick={(e) => {
-                      e.stopPropagation();
-                      configureSlot.onConfigure?.();
-                    }}
-                    className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
-                    title={t('ams.configureSlot')}
-                  >
-                    <Settings2 className="w-3.5 h-3.5" />
-                    {t('ams.configure')}
-                  </button>
-                )}
-                {onAssignSpool && (
-                  <button
-                    onClick={(e) => { e.stopPropagation(); onAssignSpool(); }}
-                    className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
-                  >
-                    <Package className="w-3.5 h-3.5" />
-                    {t('inventory.assignSpool')}
-                  </button>
-                )}
+            {configureSlot?.enabled && (
+              <div className="px-2 pb-2">
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    configureSlot.onConfigure?.();
+                  }}
+                  className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
+                  title={t('ams.configureSlot')}
+                >
+                  <Settings2 className="w-3.5 h-3.5" />
+                  {t('ams.configure')}
+                </button>
               </div>
             )}
           </div>

+ 0 - 1
frontend/src/components/LinkSpoolModal.tsx

@@ -56,7 +56,6 @@ export function LinkSpoolModal({ isOpen, onClose, tagUid, trayUuid, printerId, a
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
       queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
       showToast(t('spoolman.linkSuccess'), 'success');
       onClose();
     },

+ 199 - 456
frontend/src/components/SpoolCatalogSettings.tsx

@@ -1,34 +1,20 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { Database, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload } from 'lucide-react';
-import { api, ApiError } from '../api/client';
-import type { SpoolCatalogEntry, SpoolmanFilamentEntry } from '../api/client';
+import { api } from '../api/client';
+import type { SpoolCatalogEntry } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
 import { ConfirmModal } from './ConfirmModal';
-import { SpoolWeightUpdateModal } from './SpoolWeightUpdateModal';
 
 export function SpoolCatalogSettings() {
   const { t } = useTranslation();
   const { showToast } = useToast();
-  const queryClient = useQueryClient();
   const [catalog, setCatalog] = useState<SpoolCatalogEntry[]>([]);
   const [loading, setLoading] = useState(true);
   const [search, setSearch] = useState('');
   const fileInputRef = useRef<HTMLInputElement>(null);
 
-  // Spoolman inline-edit state
-  const [editingFilamentId, setEditingFilamentId] = useState<number | null>(null);
-  const [editingFilamentName, setEditingFilamentName] = useState('');
-  const [editingFilamentWeight, setEditingFilamentWeight] = useState('');
-  const [pendingWeightEdit, setPendingWeightEdit] = useState<{
-    filamentId: number;
-    name: string;
-    oldWeight: number | null;
-    newWeight: number;
-  } | null>(null);
-
   // Add/Edit form state
   const [showAddForm, setShowAddForm] = useState(false);
   const [editingId, setEditingId] = useState<number | null>(null);
@@ -44,92 +30,11 @@ export function SpoolCatalogSettings() {
   const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
   const [showResetConfirm, setShowResetConfirm] = useState(false);
 
-  // Spoolman filament query — hoisted to determine display mode
-  const {
-    data: spoolmanFilaments,
-    isLoading: spoolmanLoading,
-    error: spoolmanError,
-  } = useQuery<SpoolmanFilamentEntry[], Error>({
-    queryKey: ['spoolman-inventory-filaments'],
-    queryFn: () => api.getSpoolmanInventoryFilaments(),
-    retry: false, // Spoolman may be intentionally disabled (400) — don't retry
-    staleTime: 60_000,
-  });
-
-  // 400 = Spoolman explicitly disabled; all other states (data / 503 / …) mean Spoolman mode
-  const isSpoolmanDisabled =
-    !spoolmanLoading &&
-    spoolmanError instanceof ApiError &&
-    spoolmanError.status === 400;
-  const isSpoolmanMode = !spoolmanLoading && !isSpoolmanDisabled;
-
-  const patchFilamentMutation = useMutation<
-    SpoolmanFilamentEntry,
-    Error,
-    { filamentId: number; data: { name?: string; spool_weight?: number | null; keep_existing_spools?: boolean } }
-  >({
-    mutationFn: ({ filamentId, data }) => api.patchSpoolmanFilament(filamentId, data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-filaments'] });
-      showToast(t('settings.catalog.filamentUpdated'), 'success');
-      setEditingFilamentId(null);
-      setEditingFilamentName('');
-      setEditingFilamentWeight('');
-      setPendingWeightEdit(null);
-    },
-    onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else if (error instanceof ApiError && error.status === 422) {
-        showToast(t('settings.catalog.filamentUpdateInvalid'), 'error');
-      } else {
-        showToast(t('settings.catalog.filamentUpdateFailed'), 'error');
-      }
-    },
-  });
-
-  const handleFilamentSave = (f: SpoolmanFilamentEntry) => {
-    const nameChanged = editingFilamentName.trim() !== f.name;
-    const weightChanged = editingFilamentWeight !== '' && editingFilamentWeight !== String(f.spool_weight ?? '');
-    const parsedWeight = editingFilamentWeight !== '' ? parseFloat(editingFilamentWeight) : null;
-
-    if (weightChanged && parsedWeight !== null && !isNaN(parsedWeight) && parsedWeight > 0) {
-      setPendingWeightEdit({
-        filamentId: f.id,
-        name: nameChanged ? editingFilamentName.trim() : f.name,
-        oldWeight: f.spool_weight,
-        newWeight: parsedWeight,
-      });
-    } else {
-      const data: { name?: string } = {};
-      if (nameChanged && editingFilamentName.trim()) data.name = editingFilamentName.trim();
-      if (Object.keys(data).length > 0) {
-        patchFilamentMutation.mutate({ filamentId: f.id, data });
-      } else {
-        setEditingFilamentId(null);
-      }
-    }
-  };
-
-  const handleWeightModalConfirm = (keepExisting: boolean) => {
-    if (!pendingWeightEdit) return;
-    const { filamentId, name, newWeight } = pendingWeightEdit;
-    const currentFilament = spoolmanFilaments?.find(f => f.id === filamentId);
-    const nameChanged = currentFilament && name !== currentFilament.name;
-    const data: { name?: string; spool_weight: number; keep_existing_spools: boolean } = {
-      spool_weight: newWeight,
-      keep_existing_spools: keepExisting,
-    };
-    if (nameChanged) data.name = name;
-    patchFilamentMutation.mutate({ filamentId, data });
-  };
-
   const loadCatalog = useCallback(async () => {
     try {
       const entries = await api.getSpoolCatalog();
       setCatalog(entries);
-    } catch (err) {
-      console.error('SpoolCatalogSettings.loadCatalog failed:', err);
+    } catch {
       showToast(t('settings.catalog.loadFailed'), 'error');
     } finally {
       setLoading(false);
@@ -144,11 +49,6 @@ export function SpoolCatalogSettings() {
     entry.name.toLowerCase().includes(search.toLowerCase())
   );
 
-  const filteredSpoolmanFilaments = (spoolmanFilaments ?? []).filter(f =>
-    f.name.toLowerCase().includes(search.toLowerCase()) ||
-    (f.vendor?.name ?? '').toLowerCase().includes(search.toLowerCase())
-  );
-
   const handleAdd = async () => {
     if (!formName.trim() || !formWeight) {
       showToast(t('settings.catalog.nameWeightRequired'), 'error');
@@ -162,8 +62,7 @@ export function SpoolCatalogSettings() {
       setFormName('');
       setFormWeight('');
       showToast(t('settings.catalog.entryAdded'), 'success');
-    } catch (err) {
-      console.error('SpoolCatalogSettings.handleAdd failed:', err);
+    } catch {
       showToast(t('settings.catalog.addFailed'), 'error');
     } finally {
       setSaving(false);
@@ -195,8 +94,7 @@ export function SpoolCatalogSettings() {
       setFormName('');
       setFormWeight('');
       showToast(t('settings.catalog.entryUpdated'), 'success');
-    } catch (err) {
-      console.error('SpoolCatalogSettings.handleUpdate failed:', err);
+    } catch {
       showToast(t('settings.catalog.updateFailed'), 'error');
     } finally {
       setSaving(false);
@@ -209,8 +107,7 @@ export function SpoolCatalogSettings() {
       await api.deleteCatalogEntry(deleteEntry.id);
       setCatalog(prev => prev.filter(e => e.id !== deleteEntry.id));
       showToast(t('settings.catalog.entryDeleted'), 'success');
-    } catch (err) {
-      console.error('SpoolCatalogSettings.handleDelete failed:', err);
+    } catch {
       showToast(t('settings.catalog.deleteFailed'), 'error');
     } finally {
       setDeleteEntry(null);
@@ -224,8 +121,7 @@ export function SpoolCatalogSettings() {
       await api.resetSpoolCatalog();
       await loadCatalog();
       showToast(t('settings.catalog.resetSuccess'), 'success');
-    } catch (err) {
-      console.error('SpoolCatalogSettings.handleReset failed:', err);
+    } catch {
       showToast(t('settings.catalog.resetFailed'), 'error');
       setLoading(false);
     }
@@ -256,8 +152,7 @@ export function SpoolCatalogSettings() {
       setCatalog(prev => prev.filter(e => !selectedIds.has(e.id)));
       setSelectedIds(new Set());
       showToast(t('settings.catalog.bulkDeleted', { count: result.deleted }), 'success');
-    } catch (err) {
-      console.error('SpoolCatalogSettings.handleBulkDelete failed:', err);
+    } catch {
       showToast(t('settings.catalog.bulkDeleteFailed'), 'error');
     }
   };
@@ -294,14 +189,10 @@ export function SpoolCatalogSettings() {
           const entry = await api.addCatalogEntry({ name: item.name, weight: item.weight });
           setCatalog(prev => [...prev, entry].sort((a, b) => a.name.localeCompare(b.name)));
           added++;
-        } catch (err) {
-          console.error('SpoolCatalogSettings import item failed:', err);
-          skipped++;
-        }
+        } catch { skipped++; }
       }
       showToast(t('settings.catalog.imported', { added, skipped }), 'success');
-    } catch (err) {
-      console.error('SpoolCatalogSettings.handleImport failed:', err);
+    } catch {
       showToast(t('settings.catalog.importFailed'), 'error');
     }
     if (fileInputRef.current) fileInputRef.current.value = '';
@@ -312,56 +203,44 @@ export function SpoolCatalogSettings() {
       <CardHeader>
         <div className="flex items-center gap-2 mb-3">
           <Database className="w-5 h-5 text-bambu-gray" />
-          <h2 className="text-lg font-semibold text-white">
-            {isSpoolmanMode
-              ? t('settings.spoolmanFilamentCatalogTitle')
-              : t('settings.catalog.spoolCatalog')}
-          </h2>
-          <span className="text-sm text-bambu-gray">
-            ({spoolmanLoading ? '…' : isSpoolmanMode ? (spoolmanFilaments?.length ?? 0) : catalog.length})
-          </span>
+          <h2 className="text-lg font-semibold text-white">{t('settings.catalog.spoolCatalog')}</h2>
+          <span className="text-sm text-bambu-gray">({catalog.length})</span>
         </div>
-
-        {/* CRUD buttons — local mode only */}
-        {!isSpoolmanMode && !spoolmanLoading && (
-          <div className="flex items-center gap-2 flex-wrap">
-            <button
-              onClick={handleExport}
-              className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
-              title={t('settings.catalog.exportTooltip')}
-            >
-              <Download className="w-4 h-4" />
-              <span className="hidden sm:inline">{t('common.export')}</span>
-            </button>
-            <button
-              onClick={() => fileInputRef.current?.click()}
-              className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
-              title={t('settings.catalog.importTooltip')}
-            >
-              <Upload className="w-4 h-4" />
-              <span className="hidden sm:inline">{t('common.import')}</span>
-            </button>
-            <input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
-            <button
-              onClick={() => setShowResetConfirm(true)}
-              className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
-              title={t('settings.catalog.resetTooltip')}
-            >
-              <RotateCcw className="w-4 h-4" />
-              <span className="hidden sm:inline">{t('common.reset')}</span>
-            </button>
-            <button
-              onClick={() => setShowAddForm(true)}
-              className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
-            >
-              <Plus className="w-4 h-4" />
-              <span className="hidden sm:inline">{t('common.add')}</span>
-            </button>
-          </div>
-        )}
-
-        {/* Bulk-delete bar — local mode only */}
-        {!isSpoolmanMode && selectedIds.size > 0 && (
+        <div className="flex items-center gap-2 flex-wrap">
+          <button
+            onClick={handleExport}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.exportTooltip')}
+          >
+            <Download className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.export')}</span>
+          </button>
+          <button
+            onClick={() => fileInputRef.current?.click()}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.importTooltip')}
+          >
+            <Upload className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.import')}</span>
+          </button>
+          <input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
+          <button
+            onClick={() => setShowResetConfirm(true)}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.resetTooltip')}
+          >
+            <RotateCcw className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.reset')}</span>
+          </button>
+          <button
+            onClick={() => setShowAddForm(true)}
+            className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
+          >
+            <Plus className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.add')}</span>
+          </button>
+        </div>
+        {selectedIds.size > 0 && (
           <div className="flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg">
             <span className="text-sm text-red-400">
               {t('settings.catalog.selectedCount', { count: selectedIds.size })}
@@ -382,16 +261,12 @@ export function SpoolCatalogSettings() {
           </div>
         )}
       </CardHeader>
-
       <CardContent className="space-y-4">
-        {/* Description */}
         <p className="text-sm text-bambu-gray">
-          {isSpoolmanMode
-            ? t('settings.spoolmanFilamentCatalogDesc')
-            : t('settings.catalog.spoolCatalogDescription')}
+          {t('settings.catalog.spoolCatalogDescription')}
         </p>
 
-        {/* Search — always shown */}
+        {/* Search */}
         <div className="relative">
           <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
           <input
@@ -403,302 +278,177 @@ export function SpoolCatalogSettings() {
           />
         </div>
 
-        {/* Mode-determination loading spinner */}
-        {spoolmanLoading && (
+        {/* Add form */}
+        {showAddForm && (
+          <div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+            <h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
+            <div className="flex gap-2 items-center">
+              <div className="flex-1 min-w-0">
+                <input
+                  type="text"
+                  className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  placeholder={t('settings.catalog.namePlaceholder')}
+                  value={formName}
+                  onChange={(e) => setFormName(e.target.value)}
+                />
+              </div>
+              <input
+                type="number"
+                className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
+                placeholder="g"
+                value={formWeight}
+                onChange={(e) => setFormWeight(e.target.value)}
+              />
+              <span className="text-bambu-gray shrink-0">g</span>
+              <button
+                onClick={handleAdd}
+                disabled={saving}
+                className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
+              >
+                {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                {t('common.add')}
+              </button>
+              <button
+                onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
+                className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            </div>
+          </div>
+        )}
+
+        {/* Catalog list */}
+        {loading ? (
           <div className="flex items-center justify-center py-8 text-bambu-gray">
             <Loader2 className="w-5 h-5 animate-spin mr-2" />
             {t('common.loading')}
           </div>
-        )}
-
-        {/* ── LOCAL MODE ── */}
-        {!spoolmanLoading && !isSpoolmanMode && (
-          <>
-            {/* Add form */}
-            {showAddForm && (
-              <div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-                <h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
-                <div className="flex gap-2 items-center">
-                  <div className="flex-1 min-w-0">
+        ) : (
+          <div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
+            <table className="w-full text-sm">
+              <thead className="bg-bambu-dark sticky top-0">
+                <tr>
+                  <th className="px-2 py-2 w-10">
                     <input
-                      type="text"
-                      className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
-                      placeholder={t('settings.catalog.namePlaceholder')}
-                      value={formName}
-                      onChange={(e) => setFormName(e.target.value)}
+                      type="checkbox"
+                      checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
+                      onChange={toggleSelectAll}
+                      className="w-4 h-4 accent-bambu-green cursor-pointer"
                     />
-                  </div>
-                  <input
-                    type="number"
-                    className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
-                    placeholder="g"
-                    value={formWeight}
-                    onChange={(e) => setFormWeight(e.target.value)}
-                  />
-                  <span className="text-bambu-gray shrink-0">g</span>
-                  <button
-                    onClick={handleAdd}
-                    disabled={saving}
-                    className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
-                  >
-                    {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
-                    {t('common.add')}
-                  </button>
-                  <button
-                    onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
-                    className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
-                  >
-                    <X className="w-4 h-4" />
-                  </button>
-                </div>
-              </div>
-            )}
-
-            {/* Local catalog table */}
-            {loading ? (
-              <div className="flex items-center justify-center py-8 text-bambu-gray">
-                <Loader2 className="w-5 h-5 animate-spin mr-2" />
-                {t('common.loading')}
-              </div>
-            ) : (
-              <div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
-                <table className="w-full text-sm">
-                  <thead className="bg-bambu-dark sticky top-0">
-                    <tr>
-                      <th className="px-2 py-2 w-10">
-                        <input
-                          type="checkbox"
-                          checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
-                          onChange={toggleSelectAll}
-                          className="w-4 h-4 accent-bambu-green cursor-pointer"
-                        />
-                      </th>
-                      <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
-                      <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
-                      <th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
-                      <th className="px-4 py-2 w-24"></th>
-                    </tr>
-                  </thead>
-                  <tbody>
-                    {filteredCatalog.length === 0 ? (
-                      <tr>
-                        <td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
-                          {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
-                        </td>
-                      </tr>
-                    ) : (
-                      filteredCatalog.map(entry => (
-                        <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
-                          {editingId === entry.id ? (
-                            <>
-                              <td className="px-2 py-2">
-                                <input
-                                  type="checkbox"
-                                  checked={selectedIds.has(entry.id)}
-                                  onChange={() => toggleSelect(entry.id)}
-                                  className="w-4 h-4 accent-bambu-green cursor-pointer"
-                                />
-                              </td>
-                              <td className="px-4 py-2">
-                                <input
-                                  type="text"
-                                  className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
-                                  value={formName}
-                                  onChange={(e) => setFormName(e.target.value)}
-                                />
-                              </td>
-                              <td className="px-4 py-2">
-                                <input
-                                  type="number"
-                                  className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
-                                  value={formWeight}
-                                  onChange={(e) => setFormWeight(e.target.value)}
-                                />
-                              </td>
-                              <td className="px-4 py-2 text-center">
-                                <span className="text-xs text-bambu-gray">-</span>
-                              </td>
-                              <td className="px-4 py-2">
-                                <div className="flex justify-end gap-1">
-                                  <button
-                                    onClick={() => handleUpdate(entry.id)}
-                                    disabled={saving}
-                                    className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
-                                  >
-                                    {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
-                                  </button>
-                                  <button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
-                                    <X className="w-4 h-4" />
-                                  </button>
-                                </div>
-                              </td>
-                            </>
-                          ) : (
-                            <>
-                              <td className="px-2 py-2">
-                                <input
-                                  type="checkbox"
-                                  checked={selectedIds.has(entry.id)}
-                                  onChange={() => toggleSelect(entry.id)}
-                                  className="w-4 h-4 accent-bambu-green cursor-pointer"
-                                />
-                              </td>
-                              <td className="px-4 py-2 text-white">{entry.name}</td>
-                              <td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
-                              <td className="px-4 py-2 text-center">
-                                {entry.is_default ? (
-                                  <span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
-                                    {t('settings.catalog.default')}
-                                  </span>
-                                ) : (
-                                  <span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
-                                    {t('settings.catalog.custom')}
-                                  </span>
-                                )}
-                              </td>
-                              <td className="px-4 py-2">
-                                <div className="flex justify-end gap-1">
-                                  <button
-                                    onClick={() => startEdit(entry)}
-                                    className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
-                                  >
-                                    <Pencil className="w-4 h-4" />
-                                  </button>
-                                  <button
-                                    onClick={() => setDeleteEntry(entry)}
-                                    className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
-                                  >
-                                    <Trash2 className="w-4 h-4" />
-                                  </button>
-                                </div>
-                              </td>
-                            </>
-                          )}
-                        </tr>
-                      ))
-                    )}
-                  </tbody>
-                </table>
-              </div>
-            )}
-          </>
-        )}
-
-        {/* ── SPOOLMAN MODE ── */}
-        {!spoolmanLoading && isSpoolmanMode && (
-          <div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
-            {spoolmanError ? (
-              <p className="px-4 py-8 text-center text-sm text-red-400">
-                {t('inventory.spoolmanCatalogLoadFailed')}
-              </p>
-            ) : filteredSpoolmanFilaments.length === 0 ? (
-              <p className="px-4 py-8 text-center text-sm text-bambu-gray">
-                {t('inventory.noSpoolmanFilaments')}
-              </p>
-            ) : (
-              <table className="w-full text-sm">
-                <thead className="bg-bambu-dark sticky top-0">
+                  </th>
+                  <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
+                  <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
+                  <th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
+                  <th className="px-4 py-2 w-24"></th>
+                </tr>
+              </thead>
+              <tbody>
+                {filteredCatalog.length === 0 ? (
                   <tr>
-                    <th className="px-3 py-2 w-8"></th>
-                    <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
-                    <th className="px-4 py-2 text-left text-bambu-gray font-medium w-28">{t('settings.catalog.material')}</th>
-                    <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
-                    <th className="px-4 py-2 text-right text-bambu-gray font-medium w-28">{t('settings.catalog.spoolWeight')}</th>
-                    <th className="px-3 py-2 w-20"></th>
+                    <td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
+                      {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
+                    </td>
                   </tr>
-                </thead>
-                <tbody>
-                  {filteredSpoolmanFilaments.map(f => {
-                    const isEditing = editingFilamentId === f.id;
-                    const isSaving = patchFilamentMutation.isPending && editingFilamentId === f.id;
-                    return (
-                      <tr key={f.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
-                        <td className="px-3 py-2">
-                          <span
-                            className="w-4 h-4 rounded-full block shrink-0 border border-white/20"
-                            style={{ backgroundColor: f.color_hex ? `#${f.color_hex.replace('#', '')}` : '#808080' }}
-                            aria-label={t('inventory.spoolmanFilamentColorSwatch')}
-                          />
-                        </td>
-                        <td className="px-4 py-2 text-white truncate max-w-0">
-                          {isEditing ? (
+                ) : (
+                  filteredCatalog.map(entry => (
+                    <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
+                      {editingId === entry.id ? (
+                        <>
+                          <td className="px-2 py-2">
+                            <input
+                              type="checkbox"
+                              checked={selectedIds.has(entry.id)}
+                              onChange={() => toggleSelect(entry.id)}
+                              className="w-4 h-4 accent-bambu-green cursor-pointer"
+                            />
+                          </td>
+                          <td className="px-4 py-2">
                             <input
                               type="text"
-                              value={editingFilamentName}
-                              onChange={e => setEditingFilamentName(e.target.value)}
-                              className="w-full bg-bambu-dark-tertiary text-white rounded px-2 py-0.5 text-sm border border-bambu-dark-secondary focus:outline-none focus:border-bambu-green"
-                              aria-label={t('common.name')}
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
+                              value={formName}
+                              onChange={(e) => setFormName(e.target.value)}
                             />
-                          ) : (
-                            <span className="block truncate">
-                              {f.vendor?.name ? `${f.vendor.name} — ` : ''}{f.name}
-                            </span>
-                          )}
-                        </td>
-                        <td className="px-4 py-2 text-bambu-gray">{f.material ?? '—'}</td>
-                        <td className="px-4 py-2 text-right font-mono text-white">
-                          {f.weight ? `${f.weight}g` : '—'}
-                        </td>
-                        <td className="px-4 py-2 text-right font-mono text-bambu-gray">
-                          {isEditing ? (
+                          </td>
+                          <td className="px-4 py-2">
                             <input
                               type="number"
-                              value={editingFilamentWeight}
-                              onChange={e => setEditingFilamentWeight(e.target.value)}
-                              min="0"
-                              step="1"
-                              className="w-20 bg-bambu-dark-tertiary text-white rounded px-2 py-0.5 text-sm border border-bambu-dark-secondary focus:outline-none focus:border-bambu-green text-right"
-                              aria-label={t('settings.catalog.spoolWeight')}
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
+                              value={formWeight}
+                              onChange={(e) => setFormWeight(e.target.value)}
                             />
-                          ) : (
-                            f.spool_weight != null ? `${f.spool_weight}g` : '—'
-                          )}
-                        </td>
-                        <td className="px-3 py-2">
-                          {isEditing ? (
-                            <div className="flex items-center gap-1 justify-end">
+                          </td>
+                          <td className="px-4 py-2 text-center">
+                            <span className="text-xs text-bambu-gray">-</span>
+                          </td>
+                          <td className="px-4 py-2">
+                            <div className="flex justify-end gap-1">
                               <button
-                                onClick={() => handleFilamentSave(f)}
-                                disabled={isSaving || !editingFilamentName.trim() || (editingFilamentWeight !== '' && (isNaN(parseFloat(editingFilamentWeight)) || parseFloat(editingFilamentWeight) <= 0))}
-                                className="p-1 text-bambu-green hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
-                                aria-label={t('common.save')}
+                                onClick={() => handleUpdate(entry.id)}
+                                disabled={saving}
+                                className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
                               >
-                                {isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                                {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
                               </button>
+                              <button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
+                                <X className="w-4 h-4" />
+                              </button>
+                            </div>
+                          </td>
+                        </>
+                      ) : (
+                        <>
+                          <td className="px-2 py-2">
+                            <input
+                              type="checkbox"
+                              checked={selectedIds.has(entry.id)}
+                              onChange={() => toggleSelect(entry.id)}
+                              className="w-4 h-4 accent-bambu-green cursor-pointer"
+                            />
+                          </td>
+                          <td className="px-4 py-2 text-white">{entry.name}</td>
+                          <td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
+                          <td className="px-4 py-2 text-center">
+                            {entry.is_default ? (
+                              <span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
+                                {t('settings.catalog.default')}
+                              </span>
+                            ) : (
+                              <span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
+                                {t('settings.catalog.custom')}
+                              </span>
+                            )}
+                          </td>
+                          <td className="px-4 py-2">
+                            <div className="flex justify-end gap-1">
                               <button
-                                onClick={() => { setEditingFilamentId(null); setEditingFilamentName(''); setEditingFilamentWeight(''); }}
-                                className="p-1 text-bambu-gray hover:text-white"
-                                aria-label={t('common.cancel')}
+                                onClick={() => startEdit(entry)}
+                                className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
                               >
-                                <X className="w-4 h-4" />
+                                <Pencil className="w-4 h-4" />
+                              </button>
+                              <button
+                                onClick={() => setDeleteEntry(entry)}
+                                className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
+                              >
+                                <Trash2 className="w-4 h-4" />
                               </button>
                             </div>
-                          ) : (
-                            <button
-                              onClick={() => {
-                                setEditingFilamentId(f.id);
-                                setEditingFilamentName(f.name);
-                                setEditingFilamentWeight(f.spool_weight != null ? String(f.spool_weight) : '');
-                              }}
-                              className="p-1 text-bambu-gray hover:text-white"
-                              aria-label={t('common.edit')}
-                            >
-                              <Pencil className="w-4 h-4" />
-                            </button>
-                          )}
-                        </td>
-                      </tr>
-                    );
-                  })}
-                </tbody>
-              </table>
-            )}
+                          </td>
+                        </>
+                      )}
+                    </tr>
+                  ))
+                )}
+              </tbody>
+            </table>
           </div>
         )}
       </CardContent>
 
-      {/* Confirmation modals — local mode only */}
-      {!isSpoolmanMode && deleteEntry && (
+      {/* Delete confirmation */}
+      {deleteEntry && (
         <ConfirmModal
           title={t('settings.catalog.deleteEntry')}
           message={t('settings.catalog.deleteConfirm', { name: deleteEntry.name })}
@@ -709,7 +459,8 @@ export function SpoolCatalogSettings() {
         />
       )}
 
-      {!isSpoolmanMode && showBulkDeleteConfirm && (
+      {/* Bulk delete confirmation */}
+      {showBulkDeleteConfirm && (
         <ConfirmModal
           title={t('settings.catalog.deleteSelected')}
           message={t('settings.catalog.bulkDeleteConfirm', { count: selectedIds.size })}
@@ -720,7 +471,8 @@ export function SpoolCatalogSettings() {
         />
       )}
 
-      {!isSpoolmanMode && showResetConfirm && (
+      {/* Reset confirmation */}
+      {showResetConfirm && (
         <ConfirmModal
           title={t('settings.catalog.resetCatalog')}
           message={t('settings.catalog.resetConfirm')}
@@ -730,15 +482,6 @@ export function SpoolCatalogSettings() {
           onCancel={() => setShowResetConfirm(false)}
         />
       )}
-
-      <SpoolWeightUpdateModal
-        isOpen={pendingWeightEdit !== null}
-        filamentName={pendingWeightEdit?.name ?? ''}
-        oldWeight={pendingWeightEdit?.oldWeight ?? null}
-        newWeight={pendingWeightEdit?.newWeight ?? 0}
-        onConfirm={handleWeightModalConfirm}
-        onClose={() => setPendingWeightEdit(null)}
-      />
     </Card>
   );
 }

+ 51 - 205
frontend/src/components/SpoolFormModal.tsx

@@ -2,25 +2,22 @@ import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
-import { api, ApiError } from '../api/client';
-import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
+import { api } from '../api/client';
+import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
-import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
+import { defaultFormData, validateForm } from './spool-form/types';
 import { buildFilamentOptions, extractBrandsFromPresets, findPresetOption, loadRecentColors, parsePresetName, saveRecentColor } from './spool-form/utils';
 import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
-import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { PAProfileSection } from './spool-form/PAProfileSection';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
 
 type TabId = 'filament' | 'pa-profile';
 
-const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
-
 interface SpoolFormModalProps {
   isOpen: boolean;
   onClose: () => void;
@@ -28,10 +25,6 @@ interface SpoolFormModalProps {
   printersWithCalibrations?: PrinterWithCalibrations[];
   currencySymbol: string;
   onSpoolsCreated?: (spools: InventorySpool[]) => void;
-  /** When true, CRUD operations target the Spoolman inventory proxy endpoints. */
-  spoolmanMode?: boolean;
-  /** Query key to invalidate after mutations (differs for Spoolman vs local). */
-  spoolsQueryKey?: string[];
 }
 
 export function SpoolFormModal({
@@ -41,8 +34,6 @@ export function SpoolFormModal({
   printersWithCalibrations = [],
   currencySymbol,
   onSpoolsCreated,
-  spoolmanMode = false,
-  spoolsQueryKey = ['inventory-spools'],
 }: SpoolFormModalProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -55,7 +46,6 @@ export function SpoolFormModal({
   const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
   const [activeTab, setActiveTab] = useState<TabId>('filament');
   const [weightTouched, setWeightTouched] = useState(false);
-  const [storageLocationTouched, setStorageLocationTouched] = useState(false);
   const [quickAdd, setQuickAdd] = useState(false);
   const [quantity, setQuantity] = useState(1);
 
@@ -88,17 +78,9 @@ export function SpoolFormModal({
     : fetchedCalibrations;
 
   // Count selected PA profiles for tab badge
-  const selectedProfileCount = selectedProfiles.size;
-
-  // Fetch Spoolman filament catalog when in Spoolman mode
-  // retry:false — Spoolman may be intentionally disabled (400); don't flood the server
-  const { data: spoolmanFilaments = [], isLoading: isLoadingFilaments, error: filamentsError } = useQuery<SpoolmanFilamentEntry[], Error>({
-    queryKey: ['spoolman-inventory-filaments'],
-    queryFn: () => api.getSpoolmanInventoryFilaments(),
-    enabled: spoolmanMode && isOpen,
-    staleTime: 60_000,
-    retry: false,
-  });
+  const selectedProfileCount = useMemo(() => {
+    return selectedProfiles.size;
+  }, [selectedProfiles]);
 
   // Load recent colors on mount
   useEffect(() => {
@@ -125,9 +107,7 @@ export function SpoolFormModal({
         }
       };
       fetchData();
-      if (!spoolmanMode) {
-        api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
-      }
+      api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
       api.getColorCatalog().then(setColorCatalog).catch(console.error);
       api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
 
@@ -171,13 +151,6 @@ export function SpoolFormModal({
         })();
       }
     }
-    // The effect intentionally depends only on `isOpen` (and the prop-side
-    // calibration count) — re-running on every spoolmanMode toggle would
-    // race the in-flight async fetches with unmount/teardown and emit
-    // "test environment was torn down" errors in vitest. spoolmanMode only
-    // gates a single fetch (getSpoolCatalog) which is cheap enough to skip
-    // when the modal opens in Spoolman mode.
-    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isOpen, printersWithCalibrations.length]);
 
   // Build filament options: cloud → local → fallback
@@ -307,8 +280,6 @@ export function SpoolFormModal({
           cost_per_kg: spool.cost_per_kg ?? null,
           category: spool.category || '',
           low_stock_threshold_pct: spool.low_stock_threshold_pct ?? null,
-          storage_location: spool.storage_location || '',
-          spoolman_filament_id: null,
         });
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
 
@@ -334,7 +305,6 @@ export function SpoolFormModal({
       setErrors({});
       setActiveTab('filament');
       setWeightTouched(false);
-      setStorageLocationTouched(false);
     }
   }, [isOpen, spool]);
 
@@ -347,166 +317,83 @@ export function SpoolFormModal({
 
   // Update field helper
   const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
-    const isLinkedField = SPOOLMAN_LINKED_FIELDS.has(key);
-    if (spoolmanMode && isLinkedField && formData.spoolman_filament_id !== null) {
-      showToast(t('inventory.spoolmanFilamentUnlinked'), 'info');
-    }
-    setFormData(prev => ({
-      ...prev,
-      [key]: value,
-      ...(spoolmanMode && isLinkedField && prev.spoolman_filament_id !== null
-        ? { spoolman_filament_id: null }
-        : {}),
-    }));
+    setFormData(prev => ({ ...prev, [key]: value }));
     if (key === 'weight_used') setWeightTouched(true);
-    if (key === 'storage_location') setStorageLocationTouched(true);
     if (errors[key]) {
       setErrors(prev => ({ ...prev, [key]: undefined }));
     }
   };
 
-  // Prefill form from a Spoolman filament catalog entry
-  // subtype extraction mirrors _spoolman_helpers.py logic
-  const handleFilamentSelect = (filament: SpoolmanFilamentEntry) => {
-    const material = filament.material || '';
-    const name = filament.name || '';
-    const subtype = material && name.startsWith(material) ? name.slice(material.length).trim() : name;
-    const rawHex = (filament.color_hex ?? '').replace('#', '').toUpperCase();
-    // Guard against short/malformed hex values — must be exactly 6 hex chars
-    const colorHex = /^[0-9A-F]{6}$/.test(rawHex) ? rawHex : '808080';
-    setFormData(prev => ({
-      ...prev,
-      spoolman_filament_id: filament.id,
-      material,
-      subtype,
-      brand: filament.vendor?.name || '',
-      rgba: `${colorHex}FF`,
-      color_name: filament.color_name || '',
-      label_weight: filament.weight ?? prev.label_weight,
-    }));
-    showToast(t('inventory.spoolmanFilamentSelected'), 'success');
-  };
-
   // Handle color selection
   const handleColorUsed = (color: ColorPreset) => {
     setRecentColors(prev => saveRecentColor(color, prev));
   };
 
-  // Mutations – dispatch to Spoolman proxy or local inventory based on mode
+  // Mutations
   const createMutation = useMutation({
     mutationFn: (data: Record<string, unknown>) =>
-      spoolmanMode
-        ? api.createSpoolmanInventorySpool(data as Parameters<typeof api.createSpoolmanInventorySpool>[0])
-        : api.createSpool(data as Parameters<typeof api.createSpool>[0]),
+      api.createSpool(data as Parameters<typeof api.createSpool>[0]),
     onSuccess: async (newSpool) => {
-      if (newSpool?.id) {
-        const ok = await saveKProfiles(newSpool.id);
-        if (!ok) return;
+      // Save K-profiles if any selected
+      if (selectedProfiles.size > 0 && newSpool?.id) {
+        await saveKProfiles(newSpool.id);
       }
-      await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       showToast(t('inventory.spoolCreated'), 'success');
       onClose();
     },
     onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else {
-        showToast(t('inventory.saveFailed'), 'error');
-      }
+      showToast(error.message, 'error');
     },
   });
 
-  const bulkCreateMutation = useMutation<
-    SpoolmanBulkCreateResult | InventorySpool[],
-    Error,
-    { data: Record<string, unknown>; qty: number }
-  >({
-    mutationFn: ({ data, qty }) =>
-      spoolmanMode
-        ? api.bulkCreateSpoolmanInventorySpools(data as Parameters<typeof api.bulkCreateSpoolmanInventorySpools>[0], qty)
-        : api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
-    onSuccess: async (result) => {
-      // Spoolman bulk-create returns SpoolmanBulkCreateResult (207); local returns InventorySpool[].
-      // Cast via unknown to satisfy strict TypeScript — the runtime shape is guaranteed by
-      // the duck-type check ('created' in result) before any property access.
-      const spoolmanResult = (spoolmanMode && 'created' in result)
-        ? (result as unknown as SpoolmanBulkCreateResult)
-        : null;
-      const createdSpools: InventorySpool[] = spoolmanResult
-        ? spoolmanResult.created
-        : (result as InventorySpool[]);
-
+  const bulkCreateMutation = useMutation({
+    mutationFn: ({ data, qty }: { data: Record<string, unknown>; qty: number }) =>
+      api.bulkCreateSpools(data as Parameters<typeof api.bulkCreateSpools>[0], qty),
+    onSuccess: async (newSpools) => {
       if (selectedProfiles.size > 0) {
-        for (const s of createdSpools) {
-          await saveKProfiles(s.id);
+        for (const spool of newSpools) {
+          await saveKProfiles(spool.id);
         }
       }
-      await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
-      if (onSpoolsCreated) onSpoolsCreated(createdSpools);
-      if (spoolmanResult && spoolmanResult.failed_count > 0) {
-        showToast(
-          t('inventory.spoolsPartiallyCreated', {
-            created: createdSpools.length,
-            total: spoolmanResult.requested_count,
-          }),
-          'warning',
-        );
-      } else {
-        showToast(t('inventory.spoolsCreated', { count: createdSpools.length }), 'success');
-      }
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      if (onSpoolsCreated) onSpoolsCreated(newSpools);
+      showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
       onClose();
     },
     onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else {
-        showToast(t('inventory.saveFailed'), 'error');
-      }
+      showToast(error.message, 'error');
     },
   });
 
   const updateMutation = useMutation({
     mutationFn: (data: Record<string, unknown>) =>
-      spoolmanMode
-        ? api.updateSpoolmanInventorySpool(spool!.id, data as Parameters<typeof api.updateSpoolmanInventorySpool>[1])
-        : api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
+      api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
     onSuccess: async () => {
+      // Save K-profiles
       if (spool?.id) {
-        const ok = await saveKProfiles(spool.id);
-        if (!ok) return;
+        await saveKProfiles(spool.id);
       }
-      await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.spoolUpdated'), 'success');
       onClose();
     },
     onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else {
-        showToast(t('inventory.saveFailed'), 'error');
-      }
+      showToast(error.message, 'error');
     },
   });
 
   const deleteTagMutation = useMutation({
-    mutationFn: () => {
-      if (spoolmanMode) {
-        return api.updateSpoolmanInventorySpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpoolmanInventorySpool>[1]);
-      }
-      return api.updateSpool(spool!.id, CLEAR_TAG_PAYLOAD as Parameters<typeof api.updateSpool>[1]);
-    },
+    mutationFn: () =>
+      api.updateSpool(spool!.id, { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null } as Parameters<typeof api.updateSpool>[1]),
     onSuccess: async () => {
-      await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.rfidCleared', 'RFID tag cleared'), 'success');
       onClose();
     },
     onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else {
-        showToast(t('inventory.tagClearFailed'), 'error');
-      }
+      showToast(error.message, 'error');
     },
   });
 
@@ -555,29 +442,26 @@ export function SpoolFormModal({
     },
   });
 
-  // Save K-profiles for selected calibrations. Returns false if any error occurred.
-  const saveKProfiles = async (spoolId: number): Promise<boolean> => {
-    const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
-
+  // Save K-profiles for selected calibrations
+  const saveKProfiles = async (spoolId: number) => {
     if (selectedProfiles.size === 0) {
+      // Clear existing K-profiles
       try {
-        await saveApi(spoolId, []);
-        return true;
-      } catch (e) {
-        console.error('Failed to save K-profiles:', e);
-        showToast(t('inventory.kProfileSaveFailed'), 'warning');
-        return false;
+        await api.saveSpoolKProfiles(spoolId, []);
+      } catch {
+        // Ignore
       }
+      return;
     }
 
-    const profiles: SpoolKProfileInput[] = [];
-    let dropped = 0;
+    const profiles = [];
     for (const key of selectedProfiles) {
       const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
       const printerId = parseInt(printerIdStr);
       const caliIdx = parseInt(caliIdxStr);
       const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
 
+      // Find the matching calibration
       const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
       if (pc) {
         const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
@@ -591,32 +475,17 @@ export function SpoolFormModal({
             cali_idx: cal.cali_idx,
             setting_id: cal.setting_id || null,
           });
-        } else {
-          dropped++;
         }
-      } else {
-        dropped++;
       }
     }
 
-    if (dropped > 0) {
-      console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
-      showToast(t('inventory.kProfileSaveFailed'), 'warning');
-      return false;
-    }
-
     if (profiles.length > 0) {
       try {
-        await saveApi(spoolId, profiles);
-        return true;
+        await api.saveSpoolKProfiles(spoolId, profiles);
       } catch (e) {
         console.error('Failed to save K-profiles:', e);
-        showToast(t('inventory.kProfileSaveFailed'), 'warning');
-        return false;
       }
     }
-
-    return true;
   };
 
   // Close on Escape key
@@ -632,9 +501,10 @@ export function SpoolFormModal({
   if (!isOpen) return null;
 
   const handleSubmit = () => {
-    const validation = validateForm(formData, quickAdd, spoolmanMode);
+    const validation = validateForm(formData, quickAdd);
     if (!validation.isValid) {
       setErrors(validation.errors);
+      // Switch to filament tab if there are errors there
       if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
         setActiveTab('filament');
       }
@@ -645,7 +515,7 @@ export function SpoolFormModal({
     const presetName = selectedPresetOption?.displayName || presetInputValue || null;
 
     const data: Record<string, unknown> = {
-      material: formData.material || null,
+      material: formData.material,
       subtype: formData.subtype || null,
       brand: formData.brand || null,
       color_name: formData.color_name || null,
@@ -653,7 +523,8 @@ export function SpoolFormModal({
       extra_colors: formData.extra_colors || null,
       effect_type: formData.effect_type || null,
       label_weight: formData.label_weight,
-      ...(spoolmanMode ? {} : { core_weight: formData.core_weight, core_weight_catalog_id: formData.core_weight_catalog_id }),
+      core_weight: formData.core_weight,
+      core_weight_catalog_id: formData.core_weight_catalog_id,
       slicer_filament: formData.slicer_filament || null,
       slicer_filament_name: presetName,
       nozzle_temp_min: null,
@@ -662,7 +533,6 @@ export function SpoolFormModal({
       cost_per_kg: formData.cost_per_kg,
       category: formData.category.trim() || null,
       low_stock_threshold_pct: formData.low_stock_threshold_pct,
-      ...(spoolmanMode ? { spoolman_filament_id: formData.spoolman_filament_id } : {}),
     };
 
     // Only send weight_used when creating or when explicitly changed by the user.
@@ -671,13 +541,6 @@ export function SpoolFormModal({
       data.weight_used = formData.weight_used;
     }
 
-    // Only send storage_location when creating or when explicitly changed by the user.
-    // This prevents the modal round-trip from overwriting the Spoolman location field
-    // with a stale cached value when the user saves without touching this field.
-    if (!isEditing || storageLocationTouched) {
-      data.storage_location = formData.storage_location || null;
-    }
-
     if (isEditing) {
       updateMutation.mutate(data);
     } else if (quantity > 1) {
@@ -773,22 +636,6 @@ export function SpoolFormModal({
         <div className="p-4 overflow-y-auto flex-1" style={{ scrollbarGutter: 'stable' }}>
           {activeTab === 'filament' ? (
             <div className="space-y-6">
-              {/* Spoolman Filament Catalog Picker — only when creating a spool in Spoolman mode */}
-              {spoolmanMode && !isEditing && (
-                <div>
-                  {filamentsError ? (
-                    <p className="text-sm text-red-400 px-1">{t('inventory.spoolmanCatalogLoadFailed')}</p>
-                  ) : (
-                    <SpoolmanFilamentPicker
-                      filaments={spoolmanFilaments}
-                      isLoading={isLoadingFilaments}
-                      selectedId={formData.spoolman_filament_id}
-                      onSelect={handleFilamentSelect}
-                    />
-                  )}
-                </div>
-              )}
-
               {/* Filament Info Section */}
               <div>
                 <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
@@ -838,12 +685,11 @@ export function SpoolFormModal({
                   currencySymbol={currencySymbol}
                   availableCategories={availableCategories}
                   globalLowStockThreshold={globalLowStockThreshold}
-                  spoolmanMode={spoolmanMode}
                 />
               </div>
 
-              {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
-              {isEditing && spool && !spoolmanMode && (
+              {/* Usage History (only when editing) */}
+              {isEditing && spool && (
                 <div>
                   <SpoolUsageHistory spoolId={spool.id} />
                 </div>

+ 0 - 102
frontend/src/components/SpoolWeightUpdateModal.tsx

@@ -1,102 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-
-interface SpoolWeightUpdateModalProps {
-  isOpen: boolean;
-  filamentName: string;
-  oldWeight: number | null;
-  newWeight: number;
-  onConfirm: (keepExisting: boolean) => void;
-  onClose: () => void;
-}
-
-export function SpoolWeightUpdateModal({
-  isOpen,
-  filamentName,
-  oldWeight,
-  newWeight,
-  onConfirm,
-  onClose,
-}: SpoolWeightUpdateModalProps) {
-  const { t } = useTranslation();
-  const [keepExisting, setKeepExisting] = useState(false);
-
-  useEffect(() => {
-    if (isOpen) setKeepExisting(false);
-  }, [isOpen]);
-
-  if (!isOpen) return null;
-
-  const oldWeightLabel = oldWeight !== null ? `${oldWeight}g` : '—';
-  const newWeightLabel = `${newWeight}g`;
-
-  return (
-    <div
-      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={onClose}
-    >
-      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
-        <CardContent className="p-6">
-          <h3 className="text-lg font-semibold text-white mb-1">
-            {t('settings.catalog.updateSpoolWeight')}
-          </h3>
-          <p className="text-bambu-gray text-sm mb-4">
-            {filamentName}: {oldWeightLabel} → {newWeightLabel}
-          </p>
-
-          <div className="space-y-3">
-            <label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-bambu-dark-tertiary hover:bg-bambu-dark transition-colors">
-              <input
-                type="radio"
-                name="weight-update-mode"
-                checked={!keepExisting}
-                onChange={() => setKeepExisting(false)}
-                className="mt-1 accent-bambu-green"
-              />
-              <div>
-                <div className="text-sm font-medium text-white">
-                  {t('settings.catalog.applyToAllSpools')}
-                </div>
-                <div className="text-xs text-bambu-gray mt-0.5">
-                  {t('settings.catalog.applyToAllSpoolsDesc')}
-                </div>
-              </div>
-            </label>
-
-            <label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-bambu-dark-tertiary hover:bg-bambu-dark transition-colors">
-              <input
-                type="radio"
-                name="weight-update-mode"
-                checked={keepExisting}
-                onChange={() => setKeepExisting(true)}
-                className="mt-1 accent-bambu-green"
-              />
-              <div>
-                <div className="text-sm font-medium text-white">
-                  {t('settings.catalog.keepExistingSpoolWeight')}
-                </div>
-                <div className="text-xs text-bambu-gray mt-0.5">
-                  {t('settings.catalog.keepExistingSpoolWeightDesc')}
-                </div>
-              </div>
-            </label>
-          </div>
-
-          <div className="flex gap-3 mt-6">
-            <Button variant="secondary" onClick={onClose} className="flex-1">
-              {t('common.cancel')}
-            </Button>
-            <Button
-              onClick={() => onConfirm(keepExisting)}
-              className="flex-1 bg-bambu-green hover:bg-bambu-green-dark"
-            >
-              {t('common.confirm')}
-            </Button>
-          </div>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 9 - 80
frontend/src/components/SpoolmanSettings.tsx

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle, Package, ExternalLink } from 'lucide-react';
-import { api, ApiError } from '../api/client';
+import { api } from '../api/client';
 import type { SpoolmanSyncResult, Printer } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
@@ -22,7 +22,6 @@ export function SpoolmanSettings() {
   const [isInitialized, setIsInitialized] = useState(false);
   const [showAllSkipped, setShowAllSkipped] = useState(false);
   const [showAmsSyncConfirm, setShowAmsSyncConfirm] = useState(false);
-  const [showSpoolmanAmsSyncConfirm, setShowSpoolmanAmsSyncConfirm] = useState(false);
 
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
@@ -91,12 +90,8 @@ export function SpoolmanSettings() {
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['settings'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-filaments'] });
       showToast(t('settings.toast.settingsSaved'));
     },
-    onError: () => {
-      showToast(t('settings.toast.saveFailed'), 'error');
-    },
   });
 
   // Connect mutation
@@ -105,9 +100,6 @@ export function SpoolmanSettings() {
     onSuccess: () => {
       refetchStatus();
     },
-    onError: () => {
-      showToast(t('settings.toast.saveFailed'), 'error');
-    },
   });
 
   // Disconnect mutation
@@ -116,19 +108,15 @@ export function SpoolmanSettings() {
     onSuccess: () => {
       refetchStatus();
     },
-    onError: () => {
-      showToast(t('settings.toast.saveFailed'), 'error');
-    },
   });
 
   // Sync all mutation
   const syncAllMutation = useMutation({
     mutationFn: api.syncAllPrintersAms,
     onSuccess: (data: SpoolmanSyncResult) => {
-      showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced_count, skipped: 0 }), 'success');
-    },
-    onError: () => {
-      showToast(t('settings.toast.saveFailed'), 'error');
+      if (data.success) {
+        // Show success message
+      }
     },
   });
 
@@ -136,10 +124,9 @@ export function SpoolmanSettings() {
   const syncPrinterMutation = useMutation({
     mutationFn: (printerId: number) => api.syncPrinterAms(printerId),
     onSuccess: (data: SpoolmanSyncResult) => {
-      showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced_count, skipped: 0 }), 'success');
-    },
-    onError: () => {
-      showToast(t('settings.toast.saveFailed'), 'error');
+      if (data.success) {
+        // Show success message
+      }
     },
   });
 
@@ -167,26 +154,6 @@ export function SpoolmanSettings() {
     },
   });
 
-  // Spoolman AMS weight sync mutation
-  const spoolmanAmsSyncMutation = useMutation({
-    mutationFn: api.syncSpoolmanAmsWeights,
-    onSuccess: (data) => {
-      queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
-      showToast(t('settings.spoolmanAmsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success');
-      setShowSpoolmanAmsSyncConfirm(false);
-    },
-    onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 503) {
-        showToast(t('settings.spoolmanAmsSyncErrorUnreachable'), 'error');
-      } else if (error instanceof ApiError && error.status === 400) {
-        showToast(t('settings.spoolmanAmsSyncErrorNotConfigured'), 'error');
-      } else {
-        showToast(t('settings.spoolmanAmsSyncError'), 'error');
-      }
-      setShowSpoolmanAmsSyncConfirm(false);
-    },
-  });
-
   // Combine mutation states
   const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
   const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
@@ -471,9 +438,9 @@ export function SpoolmanSettings() {
               </div>
 
               {/* Error display */}
-              {(connectMutation.isError || disconnectMutation.isError) && (
+              {connectMutation.isError && (
                 <div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
-                  {((connectMutation.error || disconnectMutation.error) as Error).message}
+                  {(connectMutation.error as Error).message}
                 </div>
               )}
 
@@ -521,31 +488,6 @@ export function SpoolmanSettings() {
                 </div>
               )}
 
-              {/* Spoolman AMS weight sync */}
-              {status?.connected && (
-                <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
-                  <div className="flex items-center justify-between">
-                    <div>
-                      <p className="text-sm text-white">{t('settings.spoolmanAmsSyncButton')}</p>
-                      <p className="text-xs text-bambu-gray">{t('settings.spoolmanAmsSyncMessage')}</p>
-                    </div>
-                    <Button
-                      variant="secondary"
-                      size="sm"
-                      onClick={() => setShowSpoolmanAmsSyncConfirm(true)}
-                      disabled={spoolmanAmsSyncMutation.isPending}
-                    >
-                      {spoolmanAmsSyncMutation.isPending ? (
-                        <Loader2 className="w-4 h-4 animate-spin" />
-                      ) : (
-                        <RefreshCw className="w-4 h-4" />
-                      )}
-                      {spoolmanAmsSyncMutation.isPending ? t('settings.spoolmanAmsSyncing') : t('settings.spoolmanAmsSyncButton')}
-                    </Button>
-                  </div>
-                </div>
-              )}
-
               {/* Sync result */}
               {syncSuccess && syncResult && (
                 <div className="mt-3 space-y-2">
@@ -633,19 +575,6 @@ export function SpoolmanSettings() {
           onCancel={() => setShowAmsSyncConfirm(false)}
         />
       )}
-
-      {showSpoolmanAmsSyncConfirm && (
-        <ConfirmModal
-          title={t('settings.spoolmanAmsSyncTitle')}
-          message={t('settings.spoolmanAmsSyncMessage')}
-          confirmText={t('settings.spoolmanAmsSyncButton')}
-          variant="warning"
-          isLoading={spoolmanAmsSyncMutation.isPending}
-          loadingText={t('settings.spoolmanAmsSyncing')}
-          onConfirm={() => spoolmanAmsSyncMutation.mutate()}
-          onCancel={() => setShowSpoolmanAmsSyncConfirm(false)}
-        />
-      )}
     </Card>
   );
 }

+ 8 - 26
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -175,7 +175,6 @@ export function AdditionalSection({
   currencySymbol,
   availableCategories,
   globalLowStockThreshold,
-  spoolmanMode = false,
 }: AdditionalSectionProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
@@ -201,18 +200,14 @@ export function AdditionalSection({
 
   return (
     <div className="space-y-4">
-      {/* Empty Spool Weight — hidden in Spoolman mode (managed per filament type in Spoolman) */}
-      {spoolmanMode ? (
-        <p className="text-xs text-bambu-gray px-1">{t('inventory.spoolWeightManagedBySpoolman')}</p>
-      ) : (
-        <SpoolWeightPicker
-          catalog={spoolCatalog}
-          value={formData.core_weight}
-          onChange={(weight) => updateField('core_weight', weight)}
-          catalogId={formData.core_weight_catalog_id}
-          onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
-        />
-      )}
+      {/* Empty Spool Weight */}
+      <SpoolWeightPicker
+        catalog={spoolCatalog}
+        value={formData.core_weight}
+        onChange={(weight) => updateField('core_weight', weight)}
+        catalogId={formData.core_weight_catalog_id}
+        onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
+      />
 
       {/* Current Weight (remaining filament) */}
       <div>
@@ -378,19 +373,6 @@ export function AdditionalSection({
           onChange={(e) => updateField('note', e.target.value)}
         />
       </div>
-
-      {/* Storage Location */}
-      <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.storageLocation')}</label>
-        <input
-          type="text"
-          maxLength={255}
-          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
-          placeholder={t('inventory.storageLocationPlaceholder')}
-          value={formData.storage_location}
-          onChange={(e) => updateField('storage_location', e.target.value)}
-        />
-      </div>
     </div>
   );
 }

+ 0 - 168
frontend/src/components/spool-form/SpoolmanFilamentPicker.tsx

@@ -1,168 +0,0 @@
-import { useState, useRef, useEffect, useMemo } from 'react';
-import { ChevronDown, Loader2, Package } from 'lucide-react';
-import { useTranslation } from 'react-i18next';
-import type { SpoolmanFilamentEntry } from '../../api/client';
-
-interface SpoolmanFilamentPickerProps {
-  filaments: SpoolmanFilamentEntry[];
-  isLoading: boolean;
-  selectedId: number | null;
-  onSelect: (filament: SpoolmanFilamentEntry) => void;
-}
-
-export function SpoolmanFilamentPicker({
-  filaments,
-  isLoading,
-  selectedId,
-  onSelect,
-}: SpoolmanFilamentPickerProps) {
-  const { t } = useTranslation();
-  const [isOpen, setIsOpen] = useState(false);
-  const [search, setSearch] = useState('');
-  const containerRef = useRef<HTMLDivElement>(null);
-  const inputRef = useRef<HTMLInputElement>(null);
-
-  const selected = useMemo(
-    () => filaments.find((f) => f.id === selectedId) ?? null,
-    [filaments, selectedId]
-  );
-
-  useEffect(() => {
-    const handleClick = (e: MouseEvent) => {
-      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
-        setIsOpen(false);
-        setSearch('');
-      }
-    };
-    document.addEventListener('mousedown', handleClick);
-    return () => document.removeEventListener('mousedown', handleClick);
-  }, []);
-
-  const filtered = useMemo(() => {
-    const q = search.toLowerCase().trim();
-    if (!q) return filaments;
-    return filaments.filter(
-      (f) =>
-        f.name.toLowerCase().includes(q) ||
-        (f.material?.toLowerCase().includes(q) ?? false) ||
-        (f.vendor?.name.toLowerCase().includes(q) ?? false) ||
-        (f.color_name?.toLowerCase().includes(q) ?? false)
-    );
-  }, [filaments, search]);
-
-  useEffect(() => {
-    if (isOpen) inputRef.current?.focus();
-  }, [isOpen]);
-
-  const handleOpen = () => {
-    setIsOpen(true);
-    setSearch('');
-  };
-
-  const handleSelect = (filament: SpoolmanFilamentEntry) => {
-    onSelect(filament);
-    setIsOpen(false);
-    setSearch('');
-  };
-
-  const colorStyle = (hex: string | null): string =>
-    hex ? `#${hex.replace('#', '')}` : '#808080';
-
-  return (
-    <div ref={containerRef} className="relative">
-      <label className="block text-sm font-medium text-bambu-gray mb-1">
-        {t('inventory.pickFromSpoolmanCatalog')}
-      </label>
-
-      {/* Trigger button */}
-      <button
-        type="button"
-        onClick={handleOpen}
-        className="w-full flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-left focus:outline-none focus:border-bambu-green hover:border-bambu-gray transition-colors"
-      >
-        {isLoading ? (
-          <Loader2 className="w-4 h-4 text-bambu-gray animate-spin shrink-0" />
-        ) : selected ? (
-          <>
-            <span
-              className="w-4 h-4 rounded-full shrink-0 border border-white/20"
-              style={{ backgroundColor: colorStyle(selected.color_hex) }}
-              aria-label={t('inventory.spoolmanFilamentColorSwatch')}
-            />
-            <span className="text-white text-sm truncate flex-1">
-              {selected.vendor?.name ? `${selected.vendor.name} — ` : ''}
-              {selected.name}
-            </span>
-          </>
-        ) : (
-          <>
-            <Package className="w-4 h-4 text-bambu-gray shrink-0" />
-            <span className="text-bambu-gray text-sm flex-1">
-              {t('inventory.spoolmanFilamentCatalog')}
-            </span>
-          </>
-        )}
-        <ChevronDown className="w-4 h-4 text-bambu-gray shrink-0 ml-auto" />
-      </button>
-
-      {/* Dropdown */}
-      {isOpen && (
-        <div className="absolute z-50 w-full mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl">
-          {/* Search */}
-          <div className="p-2 border-b border-bambu-dark-tertiary">
-            <input
-              ref={inputRef}
-              type="text"
-              value={search}
-              onChange={(e) => setSearch(e.target.value)}
-              placeholder={t('inventory.pickFromSpoolmanCatalog')}
-              className="w-full px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-            />
-          </div>
-
-          {/* Items */}
-          <ul className="max-h-56 overflow-y-auto py-1">
-            {isLoading ? (
-              <li className="flex items-center justify-center gap-2 py-4 text-bambu-gray text-sm">
-                <Loader2 className="w-4 h-4 animate-spin" />
-                <span>{t('common.loading', 'Loading…')}</span>
-              </li>
-            ) : filtered.length === 0 ? (
-              <li className="py-4 text-center text-bambu-gray text-sm">
-                {t('inventory.noSpoolmanFilaments')}
-              </li>
-            ) : (
-              filtered.map((f) => (
-                <li key={f.id}>
-                  <button
-                    type="button"
-                    onClick={() => handleSelect(f)}
-                    className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-bambu-dark-tertiary transition-colors ${
-                      f.id === selectedId ? 'bg-bambu-dark-tertiary' : ''
-                    }`}
-                  >
-                    <span
-                      className="w-4 h-4 rounded-full shrink-0 border border-white/20"
-                      style={{ backgroundColor: colorStyle(f.color_hex) }}
-                      aria-label={t('inventory.spoolmanFilamentColorSwatch')}
-                    />
-                    <span className="flex-1 min-w-0">
-                      <span className="block text-white text-sm truncate">
-                        {f.vendor?.name ? `${f.vendor.name} — ` : ''}
-                        {f.name}
-                      </span>
-                      <span className="block text-bambu-gray text-xs truncate">
-                        {[f.material, f.color_name].filter(Boolean).join(' · ')}
-                        {f.weight ? ` · ${f.weight}g` : ''}
-                      </span>
-                    </span>
-                  </button>
-                </li>
-              ))
-            )}
-          </ul>
-        </div>
-      )}
-    </div>
-  );
-}

+ 3 - 29
frontend/src/components/spool-form/types.ts

@@ -31,10 +31,6 @@ export interface SpoolFormData {
   // User-defined category + per-spool low-stock threshold override (#729).
   category: string;
   low_stock_threshold_pct: number | null;
-  storage_location: string;
-  // When set the spool is linked to a specific Spoolman filament catalog entry;
-  // the backend skips find_or_create_filament() and uses this ID directly.
-  spoolman_filament_id: number | null;
 }
 
 export const defaultFormData: SpoolFormData = {
@@ -54,8 +50,6 @@ export const defaultFormData: SpoolFormData = {
   cost_per_kg: null,
   category: '',
   low_stock_threshold_pct: null,
-  storage_location: '',
-  spoolman_filament_id: null,
 };
 
 // Printer with calibrations type
@@ -131,9 +125,6 @@ export interface AdditionalSectionProps extends SectionProps {
   // Global low-stock threshold (%); shown as placeholder on the per-spool
   // override input so users see what they're overriding. #729
   globalLowStockThreshold: number;
-  // When true the empty-spool weight is managed by Spoolman on the filament
-  // object, so SpoolWeightPicker is hidden and an info notice is shown instead.
-  spoolmanMode?: boolean;
 }
 
 // PA Profile section props
@@ -145,34 +136,17 @@ export interface PAProfileSectionProps extends SectionProps {
   setExpandedPrinters: React.Dispatch<React.SetStateAction<Set<string>>>;
 }
 
-// Fields that are prefilled by SpoolmanFilamentPicker. A manual edit to any of
-// these breaks the Spoolman catalog link (clears spoolman_filament_id).
-// Defined at module scope to avoid stale-closure issues if handlers are memoised.
-export const SPOOLMAN_LINKED_FIELDS = new Set<keyof SpoolFormData>([
-  'material',
-  'subtype',
-  'brand',
-  'rgba',
-  'color_name',
-  'label_weight',
-]);
-
 // Validation result
 export interface ValidationResult {
   isValid: boolean;
   errors: Partial<Record<keyof SpoolFormData, string>>;
 }
 
-export function validateForm(
-  formData: SpoolFormData,
-  quickAdd = false,
-  spoolmanMode = false,
-): ValidationResult {
+export function validateForm(formData: SpoolFormData, quickAdd = false): ValidationResult {
   const errors: Partial<Record<keyof SpoolFormData, string>> = {};
 
-  // Quick-add and Spoolman mode only require material (unless a catalog entry is pre-selected)
-  if (quickAdd || spoolmanMode) {
-    if (!formData.material && !formData.spoolman_filament_id) {
+  if (quickAdd) {
+    if (!formData.material) {
       errors.material = 'Material is required';
     }
     return {

+ 0 - 1
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -158,7 +158,6 @@ function SpoolSlot({ tray, slotIndex, isActive, fillOverride, spoolmanFill, onCl
     <div
       className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''} ${onClick ? 'cursor-pointer hover:bg-white/5' : ''}`}
       onClick={onClick}
-      title={onClick ? `AMS Slot ${slotIndex + 1}` : undefined}
     >
       {/* Spool visualization */}
       <div className="relative w-16 h-16 mb-1">

+ 13 - 40
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -56,10 +56,9 @@ interface AssignToAmsModalProps {
   onClose: () => void;
   spool: InventorySpool;
   printerId: number | null;
-  spoolmanMode?: boolean;
 }
 
-export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMode = false }: AssignToAmsModalProps) {
+export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const [statusMessage, setStatusMessage] = useState<string | null>(null);
@@ -121,17 +120,6 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
     staleTime: 30 * 1000,
   });
 
-  const { data: spoolmanAssignments = [] } = useQuery({
-    queryKey: ['spoolman-slot-assignments', printerId],
-    queryFn: () => api.getSpoolmanSlotAssignments(printerId ?? undefined),
-    enabled: isOpen && !!spoolmanMode && printerId !== null,
-    staleTime: 30 * 1000,
-  });
-
-  const currentAssignment = spoolmanMode
-    ? spoolmanAssignments.find(a => a.spoolman_spool_id === spool.id)
-    : undefined;
-
   // Build fill-level override map from inventory assignments
   const fillOverrides = useMemo(() => {
     const map: Record<string, number> = {};
@@ -178,24 +166,17 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
     return extruderId === 1 ? 'L' : 'R';
   }, [isDualNozzle, amsExtruderMap]);
 
-  // Assign spool to AMS slot — single API call, backend handles both DB record
-  // AND MQTT auto-configuration. When the target slot is currently empty, the
-  // backend persists the assignment and skips the MQTT publish (firmware drops
-  // it anyway); on_ams_change re-fires the full configuration when filament is
-  // later inserted. The response's `pending_config` flag distinguishes that
-  // from the immediate-apply path so we can adjust the success toast.
+  // Assign spool to AMS slot — single API call, backend handles both
+  // DB record AND MQTT auto-configuration (same as SpoolStation). When the
+  // target slot is currently empty, the backend persists the assignment and
+  // skips the MQTT publish (firmware drops it anyway); on_ams_change re-fires
+  // the full configuration when filament is later inserted. The response's
+  // `pending_config` flag distinguishes that from the immediate-apply path
+  // so we can adjust the success toast.
   const configureMutation = useMutation({
     mutationFn: async ({ amsId, trayId }: { amsId: number; trayId: number }) => {
       if (!printerId) throw new Error('No printer selected');
 
-      if (spoolmanMode) {
-        return await api.assignSpoolmanSlot({
-          spoolman_spool_id: spool.id,
-          printer_id: printerId,
-          ams_id: amsId,
-          tray_id: trayId,
-        });
-      }
       return await api.assignSpool({
         spool_id: spool.id,
         printer_id: printerId,
@@ -205,10 +186,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
     },
     onSuccess: (assignment) => {
       setStatusType('success');
-      // pending_config only exists on SpoolAssignment (the local-inventory path);
-      // the Spoolman path returns InventorySpool which always implies immediate apply.
-      const pendingConfig = assignment && 'pending_config' in assignment && assignment.pending_config;
-      if (pendingConfig) {
+      if (assignment?.pending_config) {
         setStatusMessage(
           t(
             'spoolbuddy.modal.assignPendingInsert',
@@ -219,9 +197,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
         setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
       }
       queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
-      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments-all'] });
-      setTimeout(() => onClose(), pendingConfig ? 2500 : 1500);
+      setTimeout(() => onClose(), assignment?.pending_config ? 2500 : 1500);
     },
     onError: (err) => {
       setStatusType('error');
@@ -422,7 +398,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
                   <AmsUnitCard
                     key={unit.id}
                     unit={unit}
-                    activeSlot={currentAssignment?.ams_id === unit.id ? (currentAssignment.tray_id ?? null) : null}
+                    activeSlot={null}
                     onConfigureSlot={(_amsId, trayId) => handleSlotClick(unit.id, trayId)}
                     isDualNozzle={isDualNozzle}
                     nozzleSide={getNozzleSide(unit.id)}
@@ -438,16 +414,13 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
               <div className="flex gap-2 shrink-0">
                 {singleSlots.map(({ key, label, amsId, trayId, tray, isEmpty, nozzleSide, effectiveFill }) => {
                   const color = trayColorToCSS(tray.tray_color);
-                  const isActive = !!currentAssignment &&
-                    currentAssignment.ams_id === amsId &&
-                    currentAssignment.tray_id === trayId;
                   return (
                     <div
                       key={key}
                       onClick={() => handleSlotClick(amsId, trayId)}
                       className={`bg-bambu-dark-secondary rounded-lg px-3 py-2 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${
-                        isActive ? 'ring-2 ring-bambu-green' : ''
-                      } ${isWaiting ? 'opacity-50 pointer-events-none' : ''}`}
+                        isWaiting ? 'opacity-50 pointer-events-none' : ''
+                      }`}
                     >
                       <div className="relative w-10 h-10 shrink-0">
                         {isEmpty ? (

+ 3 - 17
frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx

@@ -1,7 +1,7 @@
 import { useState } from 'react';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
+import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
 import type { InventorySpool } from '../../api/client';
 import { spoolbuddyApi, api } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
@@ -28,8 +28,6 @@ interface InventorySpoolInfoCardProps {
   onClose?: () => void;
   onSyncWeight?: () => void;
   onAssignToAms?: () => void;
-  isAssigned?: boolean;
-  onUnassignFromAms?: () => void;
   className?: string;
 }
 
@@ -40,8 +38,6 @@ export function InventorySpoolInfoCard({
   onClose,
   onSyncWeight,
   onAssignToAms,
-  isAssigned,
-  onUnassignFromAms,
   className,
 }: InventorySpoolInfoCardProps) {
   const { t } = useTranslation();
@@ -254,22 +250,12 @@ export function InventorySpoolInfoCard({
       <div className="flex gap-2 justify-center">
         {onAssignToAms && (
           <button
-            onClick={isAssigned ? undefined : onAssignToAms}
-            disabled={!!isAssigned}
-            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
+            onClick={onAssignToAms}
+            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
           >
             {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
           </button>
         )}
-        {onUnassignFromAms && (
-          <button
-            onClick={onUnassignFromAms}
-            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 transition-colors min-h-[44px]"
-          >
-            <Unlink className="w-4 h-4 inline mr-1" />
-            {t('inventory.unassignSpool')}
-          </button>
-        )}
         <button
           onClick={handleSyncWeight}
           disabled={liveScaleWeight === null || syncing}

+ 1 - 1
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -108,7 +108,7 @@ export function SpoolBuddyLayout() {
   // Blanking itself is handled by swayidle/wlopm at the OS level on the kiosk device —
   // when the HDMI output powers off and the user taps the screen, labwc delivers the
   // input event to swayidle's `resume` command which re-powers HDMI. See issue #937.
-  const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid || sbState.unknownTrayUuid);
+  const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid);
   const prevTagDetected = useRef(false);
   useEffect(() => {
     if (tagDetected && !prevTagDetected.current) {

+ 5 - 17
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
+import { Check, AlertTriangle, RefreshCw } from 'lucide-react';
 import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
 import { spoolbuddyApi } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
@@ -27,11 +27,9 @@ interface SpoolInfoCardProps {
   onClose?: () => void;
   onSyncWeight?: () => void;
   onAssignToAms?: () => void;
-  isAssigned?: boolean;
-  onUnassignFromAms?: () => void;
 }
 
-export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms, isAssigned, onUnassignFromAms }: SpoolInfoCardProps) {
+export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAssignToAms }: SpoolInfoCardProps) {
   const { t } = useTranslation();
   const [syncing, setSyncing] = useState(false);
   const [synced, setSynced] = useState(false);
@@ -182,22 +180,12 @@ export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAss
       <div className="flex gap-2 justify-center">
         {onAssignToAms && (
           <button
-            onClick={isAssigned ? undefined : onAssignToAms}
-            disabled={!!isAssigned}
-            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
+            onClick={onAssignToAms}
+            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
           >
             {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
           </button>
         )}
-        {onUnassignFromAms && (
-          <button
-            onClick={onUnassignFromAms}
-            className="px-5 py-2.5 rounded-lg text-sm font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 transition-colors min-h-[44px]"
-          >
-            <Unlink className="w-4 h-4 inline mr-1" />
-            {t('inventory.unassignSpool')}
-          </button>
-        )}
         <button
           onClick={handleSyncWeight}
           disabled={scaleWeight === null || syncing}
@@ -279,7 +267,7 @@ export function UnknownTagCard({ tagUid, scaleWeight, coreWeight, onLinkSpool, o
             <svg className="w-4 h-4 inline-block mr-1.5 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
               <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
             </svg>
-            {t('inventory.assignSpool', 'Assign Spool')}
+            {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
           </button>
         )}
         {onClose && (

+ 1 - 1
frontend/src/components/spoolbuddy/TagDetectedModal.tsx

@@ -347,7 +347,7 @@ function UnknownTagView({ tagUid, scaleWeight, onAddToInventory, onLinkSpool, on
             onClick={onLinkSpool}
             className="flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
           >
-            {t('inventory.assignSpool', 'Assign Spool')}
+            {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
           </button>
         )}
         <button

+ 1 - 7
frontend/src/hooks/useSpoolBuddyState.ts

@@ -19,7 +19,6 @@ export interface SpoolBuddyState {
   rawAdc: number | null;
   matchedSpool: MatchedSpool | null;
   unknownTagUid: string | null;
-  unknownTrayUuid: string | null;
   deviceOnline: boolean;
   deviceId: string | null;
 }
@@ -27,7 +26,7 @@ export interface SpoolBuddyState {
 type Action =
   | { type: 'WEIGHT_UPDATE'; weight: number; stable: boolean; rawAdc: number; deviceId: string }
   | { type: 'TAG_MATCHED'; spool: MatchedSpool; deviceId: string }
-  | { type: 'UNKNOWN_TAG'; tagUid: string; trayUuid: string | null; deviceId: string }
+  | { type: 'UNKNOWN_TAG'; tagUid: string; deviceId: string }
   | { type: 'TAG_REMOVED'; deviceId: string }
   | { type: 'DEVICE_ONLINE'; deviceId: string }
   | { type: 'DEVICE_OFFLINE'; deviceId: string };
@@ -38,7 +37,6 @@ const initialState: SpoolBuddyState = {
   rawAdc: null,
   matchedSpool: null,
   unknownTagUid: null,
-  unknownTrayUuid: null,
   deviceOnline: false,
   deviceId: null,
 };
@@ -59,7 +57,6 @@ function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
         ...state,
         matchedSpool: action.spool,
         unknownTagUid: null,
-        unknownTrayUuid: null,
         deviceId: action.deviceId,
       };
     case 'UNKNOWN_TAG':
@@ -67,7 +64,6 @@ function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
         ...state,
         matchedSpool: null,
         unknownTagUid: action.tagUid,
-        unknownTrayUuid: action.trayUuid ?? null,
         deviceId: action.deviceId,
       };
     case 'TAG_REMOVED':
@@ -75,7 +71,6 @@ function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
         ...state,
         matchedSpool: null,
         unknownTagUid: null,
-        unknownTrayUuid: null,
       };
     case 'DEVICE_ONLINE':
       return {
@@ -138,7 +133,6 @@ export function useSpoolBuddyState() {
     dispatch({
       type: 'UNKNOWN_TAG',
       tagUid: detail.tag_uid ?? detail.data?.tag_uid ?? '',
-      trayUuid: detail.tray_uuid ?? detail.data?.tray_uuid ?? null,
       deviceId: detail.device_id ?? detail.data?.device_id ?? '',
     });
   }, []);

+ 7 - 61
frontend/src/i18n/locales/de.ts

@@ -1605,18 +1605,6 @@ export default {
     amsSyncing: 'Synchronisiere...',
     amsSyncSuccess: '{{synced}} Spule(n) synchronisiert, {{skipped}} übersprungen',
     amsSyncError: 'Synchronisierung der Gewichte vom AMS fehlgeschlagen',
-    spoolmanAmsSyncButton: 'Spoolman-Gewichte vom AMS synchronisieren',
-    spoolmanAmsSyncTitle: 'Spoolman-Spulengewichte vom AMS synchronisieren',
-    spoolmanAmsSyncMessage: 'Dabei werden alle Spoolman-Spulengewichte anhand der aktuellen AMS-Füllstandswerte der verbundenen Drucker aktualisiert. Die Drucker müssen online sein.',
-    spoolmanAmsSyncing: 'Synchronisiere...',
-    spoolmanAmsSyncSuccess: '{{synced}} Spule(n) synchronisiert, {{skipped}} übersprungen',
-    spoolmanAmsSyncError: 'Synchronisierung der Spoolman-Gewichte vom AMS fehlgeschlagen',
-    spoolmanAmsSyncErrorUnreachable: 'Synchronisierung fehlgeschlagen (Spoolman nicht erreichbar)',
-    spoolmanAmsSyncErrorNotConfigured: 'Synchronisierung fehlgeschlagen (Spoolman nicht konfiguriert)',
-    spoolmanNotConfigured: 'Spoolman nicht konfiguriert',
-    // Spoolman filament catalog section in spool catalog settings
-    spoolmanFilamentCatalogTitle: 'Spoolman-Filamentkatalog',
-    spoolmanFilamentCatalogDesc: 'Filamentnamen und Leergewichte aus deiner Spoolman-Instanz. Name und Spulengewicht sind hier editierbar; alle anderen Eigenschaften werden direkt in Spoolman verwaltet.',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',
@@ -1984,17 +1972,6 @@ export default {
       bulkDeleteConfirm: 'Möchten Sie {{count}} Einträge wirklich löschen?',
       bulkDeleted: '{{count}} Einträge gelöscht',
       bulkDeleteFailed: 'Fehler beim Löschen der Einträge',
-      material: 'Material',
-      spoolWeight: 'Spulengewicht',
-      color: 'Farbe',
-      updateSpoolWeight: 'Spulengewicht aktualisieren',
-      filamentUpdated: 'Filament aktualisiert',
-      filamentUpdateFailed: 'Filament konnte nicht aktualisiert werden',
-      filamentUpdateInvalid: 'Ungültige Filamentdaten',
-      keepExistingSpoolWeight: 'Altes Gewicht für bestehende Spulen behalten',
-      keepExistingSpoolWeightDesc: 'Bereits erstellte Spulen dieses Filamenttyps behalten das alte Leergewicht. Neue Spulen nutzen den neuen Wert.',
-      applyToAllSpools: 'Auf alle Spulen anwenden',
-      applyToAllSpoolsDesc: 'Alle Gewichtsberechnungen für diesen Filamenttyp nutzen sofort das neue Leergewicht.',
     },
     colorCatalog: {
       title: 'Farbkatalog',
@@ -3374,15 +3351,14 @@ export default {
     linkToSpoolman: 'Mit Spoolman verknüpfen',
     openInSpoolman: 'In Spoolman öffnen',
     unlinkSpool: 'Spule trennen',
-    unlinkConfirmTitle: 'Spule entfernen?',
-    unlinkConfirmMessage: 'Die Spule wird vom Slot entfernt. Die Spulendaten selbst bleiben unverändert.',
+    unlinkConfirmTitle: 'Spule entkoppeln?',
+    unlinkConfirmMessage: 'Dadurch wird die Spule von Spoolman getrennt. Die Spulendaten in Spoolman bleiben unverändert.',
     selectSpool: 'Spule auswählen',
-    noUnlinkedSpools: 'Keine nicht zugewiesenen Spulen verfügbar',
-    linkSuccess: 'Spule erfolgreich zugewiesen',
-    linkFailed: 'Spule konnte nicht zugewiesen werden',
-    unlinkSuccess: 'Spule erfolgreich entfernt',
-    unlinkFailed: 'Spule konnte nicht entfernt werden',
-    linkedSpool: 'Zugewiesene Spule',
+    noUnlinkedSpools: 'Keine nicht verknüpften Spulen verfügbar',
+    linkSuccess: 'Spule erfolgreich mit Spoolman verknüpft',
+    linkFailed: 'Verknüpfung mit Spoolman fehlgeschlagen',
+    unlinkSuccess: 'Spule erfolgreich von Spoolman getrennt',
+    unlinkFailed: 'Trennen der Spule von Spoolman fehlgeschlagen',
     spoolId: 'Spulen-ID',
     fillSourceLabel: '(Spoolman)',
     weight: 'Gewicht',
@@ -3459,9 +3435,6 @@ export default {
     measuredWeight: 'Gemessenes Gewicht',
     spoolName: 'Spule',
     costPerKg: 'Kosten pro kg',
-    storageLocation: 'Lagerstandort',
-    storageLocationPlaceholder: 'z.B. Regal A, Schublade 1',
-    openInInventory: 'Im Inventar öffnen',
     measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.',
     slicerFilament: 'Slicer-Filament',
     slicerFilamentName: 'Slicer-Preset-Name',
@@ -3496,7 +3469,6 @@ export default {
     assigning: 'Wird zugewiesen...',
     searchSpools: 'Spulen suchen...',
     showAllSpools: 'Alle Spulen anzeigen',
-    spoolmanSpools: 'Spoolman-Spulen',
     allMaterials: 'Alle Materialien',
     filterByBrand: 'Nach Marke filtern...',
     showArchived: 'Archivierte anzeigen',
@@ -3505,27 +3477,11 @@ export default {
     stock: 'Lager',
     configured: 'Konfiguriert',
     spoolsCreated: '{{count}} Spulen erstellt',
-    spoolsPartiallyCreated: '{{created}} von {{total}} Spulen erstellt (einige fehlgeschlagen)',
     spoolCreated: 'Spule erstellt',
     spoolUpdated: 'Spule aktualisiert',
     spoolDeleted: 'Spule gelöscht',
-    deepLinkSpoolNotFound: 'Spule nicht gefunden',
-    deepLinkFetchFailed: 'Spule konnte nicht geladen werden — bitte erneut versuchen',
     spoolArchived: 'Spule archiviert',
     spoolRestored: 'Spule wiederhergestellt',
-    kProfileSaveFailed: 'K-Profil-Einstellungen konnten nicht gespeichert werden',
-    syncWeightSpoolNotFound: 'Spule nicht gefunden — sie wurde möglicherweise gelöscht',
-    syncWeightSpoolmanUnreachable: 'Spoolman ist nicht erreichbar — bitte später erneut versuchen',
-    syncWeightFailed: 'Gewicht konnte nicht synchronisiert werden',
-    spoolmanUnreachable: 'Spoolman ist nicht erreichbar — bitte später erneut versuchen',
-    deleteSpoolNotFound: 'Spule nicht gefunden — sie wurde möglicherweise bereits gelöscht',
-    deleteFailed: 'Spule konnte nicht gelöscht werden',
-    archiveSpoolNotFound: 'Spule nicht gefunden — sie wurde möglicherweise bereits gelöscht',
-    archiveFailed: 'Spule konnte nicht archiviert werden',
-    restoreSpoolNotFound: 'Spule nicht gefunden — sie wurde möglicherweise bereits gelöscht',
-    restoreFailed: 'Spule konnte nicht wiederhergestellt werden',
-    saveFailed: 'Änderungen konnten nicht gespeichert werden',
-    tagClearFailed: 'Tag konnte nicht gelöscht werden',
     deleteConfirm: 'Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
     archiveConfirm: 'Möchten Sie diese Spule wirklich archivieren?',
     advancedSettings: 'Erweiterte Einstellungen',
@@ -3654,15 +3610,6 @@ export default {
     assignMismatchConfirm: 'Trotzdem zuweisen',
     assignPartialMismatchMessage: 'Das Spulenmaterial "{{spoolMaterial}}" ist ähnlich, stimmt aber nicht genau mit "{{trayMaterial}}" in {{location}} überein. Möchten Sie fortfahren?',
     assignProfileMismatchMessage: 'Das Spulenprofil "{{spoolProfile}}" stimmt nicht mit dem Fachprofil "{{trayProfile}}" in {{location}} überein. Möchten Sie fortfahren?',
-    // Spoolman filament catalog picker
-    spoolmanFilamentCatalog: 'Spoolman-Filamentkatalog',
-    pickFromSpoolmanCatalog: 'Aus Spoolman-Katalog wählen…',
-    spoolmanFilamentSelected: 'Filament aus Spoolman-Katalog ausgewählt',
-    spoolmanFilamentUnlinked: 'Verknüpfung mit Filamentkatalog aufgehoben',
-    noSpoolmanFilaments: 'Keine Filamente im Spoolman-Katalog gefunden',
-    spoolmanFilamentColorSwatch: 'Filamentfarbe',
-    spoolWeightManagedBySpoolman: 'Das Leerspulengewicht wird pro Filamenttyp in Spoolman verwaltet',
-    spoolmanCatalogLoadFailed: 'Spoolman-Filamentkatalog konnte nicht geladen werden',
   },
 
   // Timelapse
@@ -5288,7 +5235,6 @@ export default {
       creating: 'Wird erstellt...',
       spoolCreated: 'Spule erstellt! Bereit zum Schreiben.',
       createFailed: 'Spule konnte nicht erstellt werden',
-      incompleteDataWarning: 'Tag mit unvollständigen Spoolman-Daten geschrieben',
     },
     quickMenu: {
       printerPower: 'Drucker-Strom',

+ 7 - 61
frontend/src/i18n/locales/en.ts

@@ -1608,18 +1608,6 @@ export default {
     amsSyncing: 'Syncing...',
     amsSyncSuccess: '{{synced}} spool(s) synced, {{skipped}} skipped',
     amsSyncError: 'Failed to sync weights from AMS',
-    spoolmanAmsSyncButton: 'Sync Spoolman Weights from AMS',
-    spoolmanAmsSyncTitle: 'Sync Spoolman Spool Weights from AMS',
-    spoolmanAmsSyncMessage: 'This will update all Spoolman spool weights based on the current AMS remain% values from connected printers. Printers must be online.',
-    spoolmanAmsSyncing: 'Syncing...',
-    spoolmanAmsSyncSuccess: '{{synced}} spool(s) synced, {{skipped}} skipped',
-    spoolmanAmsSyncError: 'Failed to sync Spoolman weights from AMS',
-    spoolmanAmsSyncErrorUnreachable: 'Failed to sync Spoolman weights (Spoolman unreachable)',
-    spoolmanAmsSyncErrorNotConfigured: 'Failed to sync Spoolman weights (Spoolman not configured)',
-    spoolmanNotConfigured: 'Spoolman not configured',
-    // Spoolman filament catalog section in spool catalog settings
-    spoolmanFilamentCatalogTitle: 'Spoolman Filament Catalog',
-    spoolmanFilamentCatalogDesc: 'Filament names and tare weights from your Spoolman instance. Name and spool weight are editable here; all other properties are managed directly in Spoolman.',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',
@@ -1987,17 +1975,6 @@ export default {
       bulkDeleteConfirm: 'Are you sure you want to delete {{count}} entries?',
       bulkDeleted: 'Deleted {{count}} entries',
       bulkDeleteFailed: 'Failed to delete entries',
-      material: 'Material',
-      spoolWeight: 'Spool Weight',
-      color: 'Color',
-      updateSpoolWeight: 'Update Spool Weight',
-      filamentUpdated: 'Filament updated',
-      filamentUpdateFailed: 'Failed to update filament',
-      filamentUpdateInvalid: 'Invalid filament data',
-      keepExistingSpoolWeight: 'Keep old weight for existing spools',
-      keepExistingSpoolWeightDesc: 'Spools already created with this filament type retain the old tare weight. New spools use the updated value.',
-      applyToAllSpools: 'Apply to all spools',
-      applyToAllSpoolsDesc: 'All weight calculations for this filament type immediately use the new tare weight.',
     },
     colorCatalog: {
       title: 'Color Catalog',
@@ -3377,15 +3354,14 @@ export default {
     linkToSpoolman: 'Link to Spoolman',
     openInSpoolman: 'Open in Spoolman',
     unlinkSpool: 'Unlink Spool',
-    unlinkConfirmTitle: 'Unassign Spool?',
-    unlinkConfirmMessage: 'This will remove the spool from this slot. The spool data itself will remain unchanged.',
+    unlinkConfirmTitle: 'Unlink Spool?',
+    unlinkConfirmMessage: 'This will disconnect the spool from Spoolman. The spool data in Spoolman will remain unchanged.',
     selectSpool: 'Select Spool',
-    noUnlinkedSpools: 'No unassigned spools available',
-    linkSuccess: 'Spool assigned successfully',
-    linkFailed: 'Failed to assign spool',
-    unlinkSuccess: 'Spool unassigned successfully',
-    unlinkFailed: 'Failed to unassign spool',
-    linkedSpool: 'Assigned spool',
+    noUnlinkedSpools: 'No unlinked spools available',
+    linkSuccess: 'Spool linked to Spoolman successfully',
+    linkFailed: 'Failed to link spool',
+    unlinkSuccess: 'Spool unlinked from Spoolman successfully',
+    unlinkFailed: 'Failed to unlink spool',
     spoolId: 'Spool ID',
     fillSourceLabel: '(Spoolman)',
     weight: 'Weight',
@@ -3462,9 +3438,6 @@ export default {
     measuredWeight: 'Measured Weight',
     spoolName: 'Spool',
     costPerKg: 'Cost per kg',
-    storageLocation: 'Storage Location',
-    storageLocationPlaceholder: 'e.g. Shelf A, Drawer 1',
-    openInInventory: 'Open in Inventory',
     measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.',
     slicerFilament: 'Slicer Filament',
     slicerFilamentName: 'Slicer Preset Name',
@@ -3500,7 +3473,6 @@ export default {
     assigning: 'Assigning...',
     searchSpools: 'Search spools...',
     showAllSpools: 'Show all spools',
-    spoolmanSpools: 'Spoolman Spools',
     allMaterials: 'All Materials',
     filterByBrand: 'Filter by brand...',
     showArchived: 'Show archived',
@@ -3509,27 +3481,11 @@ export default {
     stock: 'Stock',
     configured: 'Configured',
     spoolsCreated: '{{count}} spools created',
-    spoolsPartiallyCreated: '{{created}} of {{total}} spools created (some failed)',
     spoolCreated: 'Spool created',
     spoolUpdated: 'Spool updated',
     spoolDeleted: 'Spool deleted',
-    deepLinkSpoolNotFound: 'Spool not found',
-    deepLinkFetchFailed: 'Could not load spool — try again',
     spoolArchived: 'Spool archived',
     spoolRestored: 'Spool restored',
-    kProfileSaveFailed: 'K-profile settings could not be saved',
-    syncWeightSpoolNotFound: 'Spool not found — it may have been deleted',
-    syncWeightSpoolmanUnreachable: 'Spoolman is unreachable — try again later',
-    syncWeightFailed: 'Failed to sync weight',
-    spoolmanUnreachable: 'Spoolman is not reachable — please try again later',
-    deleteSpoolNotFound: 'Spool not found — it may have already been deleted',
-    deleteFailed: 'Failed to delete spool',
-    archiveSpoolNotFound: 'Spool not found — it may have already been deleted',
-    archiveFailed: 'Failed to archive spool',
-    restoreSpoolNotFound: 'Spool not found — it may have already been deleted',
-    restoreFailed: 'Failed to restore spool',
-    saveFailed: 'Failed to save changes',
-    tagClearFailed: 'Failed to clear tag',
     deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',
     archiveConfirm: 'Are you sure you want to archive this spool?',
     advancedSettings: 'Advanced Settings',
@@ -3662,15 +3618,6 @@ export default {
     assignMismatchConfirm: 'Assign Anyway',
     assignPartialMismatchMessage: 'The spool material "{{spoolMaterial}}" is similar to but not exactly matching "{{trayMaterial}}" in {{location}}. Do you want to proceed?',
     assignProfileMismatchMessage: 'The spool profile "{{spoolProfile}}" does not match the tray profile "{{trayProfile}}" in {{location}}. Do you want to proceed?',
-    // Spoolman filament catalog picker
-    spoolmanFilamentCatalog: 'Spoolman Filament Catalog',
-    pickFromSpoolmanCatalog: 'Pick from Spoolman catalog…',
-    spoolmanFilamentSelected: 'Filament selected from Spoolman catalog',
-    spoolmanFilamentUnlinked: 'Filament catalog link cleared',
-    noSpoolmanFilaments: 'No filaments found in Spoolman catalog',
-    spoolmanFilamentColorSwatch: 'Filament color',
-    spoolWeightManagedBySpoolman: 'Empty spool weight is managed per filament type in Spoolman',
-    spoolmanCatalogLoadFailed: 'Failed to load Spoolman filament catalog',
   },
 
   // Timelapse
@@ -5297,7 +5244,6 @@ export default {
       creating: 'Creating...',
       spoolCreated: 'Spool created! Ready to write.',
       createFailed: 'Failed to create spool',
-      incompleteDataWarning: 'Tag written with incomplete Spoolman data',
     },
     quickMenu: {
       printerPower: 'Printer Power',

+ 6 - 60
frontend/src/i18n/locales/fr.ts

@@ -1562,18 +1562,6 @@ export default {
     amsSyncing: 'Synchronisation...',
     amsSyncSuccess: '{{synced}} bobine(s) synchronisée(s), {{skipped}} ignorée(s)',
     amsSyncError: 'Échec de la synchronisation des poids depuis l\'AMS',
-    spoolmanAmsSyncButton: 'Synchroniser les poids Spoolman depuis l\'AMS',
-    spoolmanAmsSyncTitle: 'Synchroniser les poids des bobines Spoolman depuis l\'AMS',
-    spoolmanAmsSyncMessage: 'Cela mettra à jour les poids des bobines Spoolman en fonction des valeurs de remplissage AMS actuelles des imprimantes connectées. Les imprimantes doivent être en ligne.',
-    spoolmanAmsSyncing: 'Synchronisation...',
-    spoolmanAmsSyncSuccess: '{{synced}} bobine(s) synchronisée(s), {{skipped}} ignorée(s)',
-    spoolmanAmsSyncError: 'Échec de la synchronisation des poids Spoolman depuis l\'AMS',
-    spoolmanAmsSyncErrorUnreachable: 'Échec de la synchronisation (Spoolman inaccessible)',
-    spoolmanAmsSyncErrorNotConfigured: 'Échec de la synchronisation (Spoolman non configuré)',
-    spoolmanNotConfigured: 'Spoolman non configuré',
-    // Spoolman filament catalog section in spool catalog settings
-    spoolmanFilamentCatalogTitle: 'Catalogue de filaments Spoolman',
-    spoolmanFilamentCatalogDesc: 'Noms de filaments et poids à vide depuis votre instance Spoolman. Le nom et le poids bobine sont modifiables ici ; toutes les autres propriétés sont gérées directement dans Spoolman.',
     // Spoolman settings
     spoolmanUrl: 'URL Spoolman',
     spoolmanUrlHint: 'URL de votre serveur Spoolman (ex: http://localhost:7912)',
@@ -1941,17 +1929,6 @@ export default {
       bulkDeleteConfirm: 'Supprimer {{count}} entrées ?',
       bulkDeleted: '{{count}} entrées supprimées',
       bulkDeleteFailed: 'Échec de la suppression',
-      material: 'Matériau',
-      spoolWeight: 'Poids bobine',
-      color: 'Couleur',
-      updateSpoolWeight: 'Mettre à jour le poids bobine',
-      filamentUpdated: 'Filament mis à jour',
-      filamentUpdateFailed: 'Échec de la mise à jour du filament',
-      filamentUpdateInvalid: 'Données de filament invalides',
-      keepExistingSpoolWeight: 'Conserver l\'ancien poids pour les bobines existantes',
-      keepExistingSpoolWeightDesc: 'Les bobines déjà créées avec ce type de filament conservent l\'ancien poids à vide. Les nouvelles bobines utilisent la valeur mise à jour.',
-      applyToAllSpools: 'Appliquer à toutes les bobines',
-      applyToAllSpoolsDesc: 'Tous les calculs de poids pour ce type de filament utilisent immédiatement le nouveau poids à vide.',
     },
     colorCatalog: {
       title: 'Catalogue de Couleurs',
@@ -3361,15 +3338,14 @@ export default {
     linkToSpoolman: 'Lier à Spoolman',
     openInSpoolman: 'Ouvrir Spoolman',
     unlinkSpool: 'Délier bobine',
-    unlinkConfirmTitle: 'Désassigner la bobine ?',
-    unlinkConfirmMessage: 'Cela retirera la bobine de cet emplacement. Les données de la bobine elles-mêmes resteront inchangées.',
+    unlinkConfirmTitle: 'Dissocier la bobine?',
+    unlinkConfirmMessage: 'Cette opération déconnectera la bobine de Spoolman. Les données de la bobine dans Spoolman resteront inchangées.',
     selectSpool: 'Choisir bobine',
     noUnlinkedSpools: 'Pas de bobine libre',
-    linkSuccess: 'Bobine assignée avec succès',
-    linkFailed: 'Échec de l’assignation',
-    unlinkSuccess: 'Bobine désassignée avec succès',
-    unlinkFailed: 'Échec du désassignement',
-    linkedSpool: 'Bobine assignée',
+    linkSuccess: 'Lien réussi',
+    linkFailed: 'Échec lien',
+    unlinkSuccess: 'Bobine dissociée avec succès',
+    unlinkFailed: 'Échec de la dissociation de la bobine',
     spoolId: 'ID Bobine',
     fillSourceLabel: '(Spoolman)',
     weight: 'Poids',
@@ -3446,9 +3422,6 @@ export default {
     measuredWeight: 'Poids mesuré',
     spoolName: 'Bobine',
     costPerKg: 'Coût par kg',
-    storageLocation: 'Emplacement de stockage',
-    storageLocationPlaceholder: 'ex. Étagère A, Tiroir 1',
-    openInInventory: "Ouvrir dans l'inventaire",
     measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.',
     slicerFilament: 'Filament Slicer',
     slicerFilamentName: 'Nom du Preset Slicer',
@@ -3482,7 +3455,6 @@ export default {
     assigning: 'Assignation...',
     searchSpools: 'Chercher bobines...',
     showAllSpools: 'Afficher toutes les bobines',
-    spoolmanSpools: 'Bobines Spoolman',
     allMaterials: 'Tous Matériaux',
     filterByBrand: 'Filtrer par marque...',
     showArchived: 'Afficher archivées',
@@ -3491,27 +3463,11 @@ export default {
     stock: 'Stock',
     configured: 'Configuré',
     spoolsCreated: '{{count}} bobines créées',
-    spoolsPartiallyCreated: '{{created}} sur {{total}} bobines créées (certaines ont échoué)',
     spoolCreated: 'Bobine créée',
     spoolUpdated: 'Bobine mise à jour',
     spoolDeleted: 'Bobine supprimée',
-    deepLinkSpoolNotFound: 'Bobine introuvable',
-    deepLinkFetchFailed: 'Impossible de charger la bobine — réessayez',
     spoolArchived: 'Bobine archivée',
     spoolRestored: 'Bobine restaurée',
-    kProfileSaveFailed: 'Les paramètres de profil K n\'ont pas pu être enregistrés',
-    syncWeightSpoolNotFound: 'Bobine introuvable — elle a peut-être été supprimée',
-    syncWeightSpoolmanUnreachable: 'Spoolman est inaccessible — réessayez plus tard',
-    syncWeightFailed: 'Échec de la synchronisation du poids',
-    spoolmanUnreachable: 'Spoolman est inaccessible — veuillez réessayer plus tard',
-    deleteSpoolNotFound: 'Bobine introuvable — elle a peut-être déjà été supprimée',
-    deleteFailed: 'Impossible de supprimer la bobine',
-    archiveSpoolNotFound: 'Bobine introuvable — elle a peut-être déjà été supprimée',
-    archiveFailed: 'Impossible d\'archiver la bobine',
-    restoreSpoolNotFound: 'Bobine introuvable — elle a peut-être déjà été supprimée',
-    restoreFailed: 'Impossible de restaurer la bobine',
-    saveFailed: 'Impossible d\'enregistrer les modifications',
-    tagClearFailed: 'Impossible de supprimer le tag',
     deleteConfirm: 'Supprimer définitivement cette bobine ?',
     archiveConfirm: 'Voulez-vous vraiment archiver cette bobine ?',
     advancedSettings: 'Paramètres Avancés',
@@ -3641,15 +3597,6 @@ export default {
     assignMismatchConfirm: 'Assigner quand même',
     assignPartialMismatchMessage: 'Le matériau de la bobine "{{spoolMaterial}}" est similaire, mais ne correspond pas exactement à "{{trayMaterial}}" dans {{location}}. Voulez-vous continuer ?',
     assignProfileMismatchMessage: 'Le profil de la bobine "{{spoolProfile}}" ne correspond pas au profil du plateau "{{trayProfile}}" dans {{location}}. Voulez-vous continuer ?',
-    // Spoolman filament catalog picker
-    spoolmanFilamentCatalog: 'Catalogue de filaments Spoolman',
-    pickFromSpoolmanCatalog: 'Choisir dans le catalogue Spoolman…',
-    spoolmanFilamentSelected: 'Filament sélectionné depuis le catalogue Spoolman',
-    spoolmanFilamentUnlinked: 'Lien avec le catalogue de filaments supprimé',
-    noSpoolmanFilaments: 'Aucun filament trouvé dans le catalogue Spoolman',
-    spoolmanFilamentColorSwatch: 'Couleur du filament',
-    spoolWeightManagedBySpoolman: 'Le poids de la bobine vide est géré par type de filament dans Spoolman',
-    spoolmanCatalogLoadFailed: 'Impossible de charger le catalogue Spoolman',
   },
 
   // Timelapse
@@ -5276,7 +5223,6 @@ export default {
       creating: 'Création...',
       spoolCreated: 'Bobine créée ! Prêt à écrire.',
       createFailed: 'Impossible de créer la bobine',
-      incompleteDataWarning: 'Tag écrit avec des données Spoolman incomplètes',
     },
     quickMenu: {
       printerPower: 'Alimentation imprimante',

+ 7 - 61
frontend/src/i18n/locales/it.ts

@@ -1562,18 +1562,6 @@ export default {
     amsSyncing: 'Sincronizzazione...',
     amsSyncSuccess: '{{synced}} bobina/e sincronizzata/e, {{skipped}} saltata/e',
     amsSyncError: 'Impossibile sincronizzare i pesi dall\'AMS',
-    spoolmanAmsSyncButton: 'Sincronizza i pesi Spoolman dall\'AMS',
-    spoolmanAmsSyncTitle: 'Sincronizza i pesi delle bobine Spoolman dall\'AMS',
-    spoolmanAmsSyncMessage: 'Questo aggiornerà i pesi di tutte le bobine Spoolman in base ai valori di riempimento AMS correnti delle stampanti connesse. Le stampanti devono essere online.',
-    spoolmanAmsSyncing: 'Sincronizzazione...',
-    spoolmanAmsSyncSuccess: '{{synced}} bobina/e sincronizzata/e, {{skipped}} saltata/e',
-    spoolmanAmsSyncError: 'Impossibile sincronizzare i pesi Spoolman dall\'AMS',
-    spoolmanAmsSyncErrorUnreachable: 'Sincronizzazione fallita (Spoolman non raggiungibile)',
-    spoolmanAmsSyncErrorNotConfigured: 'Sincronizzazione fallita (Spoolman non configurato)',
-    spoolmanNotConfigured: 'Spoolman non configurato',
-    // Spoolman filament catalog section in spool catalog settings
-    spoolmanFilamentCatalogTitle: 'Catalogo filamenti Spoolman',
-    spoolmanFilamentCatalogDesc: 'Nomi dei filamenti e pesi tara dalla tua istanza Spoolman. Nome e peso bobina sono modificabili qui; tutte le altre proprietà sono gestite direttamente in Spoolman.',
     // Spoolman settings
     spoolmanUrl: 'URL Spoolman',
     spoolmanUrlHint: 'URL del server Spoolman (es. http://localhost:7912)',
@@ -1941,17 +1929,6 @@ export default {
       bulkDeleteConfirm: 'Eliminare {{count}} voci?',
       bulkDeleted: '{{count}} voci eliminate',
       bulkDeleteFailed: 'Impossibile eliminare le voci',
-      material: 'Materiale',
-      spoolWeight: 'Peso bobina',
-      color: 'Colore',
-      updateSpoolWeight: 'Aggiorna peso bobina',
-      filamentUpdated: 'Filamento aggiornato',
-      filamentUpdateFailed: 'Aggiornamento filamento non riuscito',
-      filamentUpdateInvalid: 'Dati filamento non validi',
-      keepExistingSpoolWeight: 'Mantieni vecchio peso per le bobine esistenti',
-      keepExistingSpoolWeightDesc: 'Le bobine già create con questo tipo di filamento mantengono il vecchio peso tara. Le nuove bobine usano il valore aggiornato.',
-      applyToAllSpools: 'Applica a tutte le bobine',
-      applyToAllSpoolsDesc: 'Tutti i calcoli del peso per questo tipo di filamento usano immediatamente il nuovo peso tara.',
     },
     colorCatalog: {
       title: 'Catalogo colori',
@@ -3360,15 +3337,14 @@ export default {
     linkToSpoolman: 'Collega a Spoolman',
     openInSpoolman: 'Apri in Spoolman',
     unlinkSpool: 'Scollega bobina',
-    unlinkConfirmTitle: 'Rimuovere bobina?',
-    unlinkConfirmMessage: 'Questo rimuoverà la bobina da questo slot. I dati della bobina stessa rimarranno invariati.',
+    unlinkConfirmTitle: 'Scollegare bobina?',
+    unlinkConfirmMessage: 'Questo disconnetterà lo spool da Spoolman. I dati dello spool in Spoolman rimarranno invariati.',
     selectSpool: 'Seleziona bobina',
-    noUnlinkedSpools: 'Nessuna bobina disponibile',
-    linkSuccess: 'Bobina assegnata con successo',
-    linkFailed: 'Impossibile assegnare la bobina',
-    unlinkSuccess: 'Bobina rimossa con successo',
-    unlinkFailed: 'Impossibile rimuovere la bobina',
-    linkedSpool: 'Bobina assegnata',
+    noUnlinkedSpools: 'Nessuna bobina scollegata disponibile',
+    linkSuccess: 'Bobina collegata a Spoolman con successo',
+    linkFailed: 'Collegamento bobina fallito',
+    unlinkSuccess: 'Bobina scollegata da Spoolman con successo',
+    unlinkFailed: 'Impossibile scollegare la bobina',
     spoolId: 'ID bobina',
     fillSourceLabel: '(Spoolman)',
     weight: 'Peso',
@@ -3445,9 +3421,6 @@ export default {
     measuredWeight: 'Peso Misurato',
     spoolName: 'Bobina',
     costPerKg: 'Costo per kg',
-    storageLocation: 'Posizione di archiviazione',
-    storageLocationPlaceholder: 'es. Scaffale A, Cassetto 1',
-    openInInventory: "Apri nell'inventario",
     measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.',
     slicerFilament: 'Filamento Slicer',
     slicerFilamentName: 'Nome Preset Slicer',
@@ -3481,7 +3454,6 @@ export default {
     assigning: 'Assegnazione...',
     searchSpools: 'Cerca bobine...',
     showAllSpools: 'Mostra tutte le bobine',
-    spoolmanSpools: 'Bobine Spoolman',
     allMaterials: 'Tutti i Materiali',
     filterByBrand: 'Filtra per marchio...',
     showArchived: 'Mostra archiviate',
@@ -3490,27 +3462,11 @@ export default {
     stock: 'Scorta',
     configured: 'Configurata',
     spoolsCreated: '{{count}} bobine create',
-    spoolsPartiallyCreated: '{{created}} di {{total}} bobine create (alcune non riuscite)',
     spoolCreated: 'Bobina creata',
     spoolUpdated: 'Bobina aggiornata',
     spoolDeleted: 'Bobina eliminata',
-    deepLinkSpoolNotFound: 'Bobina non trovata',
-    deepLinkFetchFailed: 'Impossibile caricare la bobina — riprova',
     spoolArchived: 'Bobina archiviata',
     spoolRestored: 'Bobina ripristinata',
-    kProfileSaveFailed: 'Impossibile salvare le impostazioni del profilo K',
-    syncWeightSpoolNotFound: 'Bobina non trovata — potrebbe essere stata eliminata',
-    syncWeightSpoolmanUnreachable: 'Spoolman non è raggiungibile — riprovare più tardi',
-    syncWeightFailed: 'Sincronizzazione del peso non riuscita',
-    spoolmanUnreachable: 'Spoolman non è raggiungibile — riprovare più tardi',
-    deleteSpoolNotFound: 'Bobina non trovata — potrebbe essere già stata eliminata',
-    deleteFailed: 'Impossibile eliminare la bobina',
-    archiveSpoolNotFound: 'Bobina non trovata — potrebbe essere già stata eliminata',
-    archiveFailed: 'Impossibile archiviare la bobina',
-    restoreSpoolNotFound: 'Bobina non trovata — potrebbe essere già stata eliminata',
-    restoreFailed: 'Impossibile ripristinare la bobina',
-    saveFailed: 'Impossibile salvare le modifiche',
-    tagClearFailed: 'Impossibile cancellare il tag',
     deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
     archiveConfirm: 'Sei sicuro di voler archiviare questa bobina?',
     advancedSettings: 'Impostazioni Avanzate',
@@ -3640,15 +3596,6 @@ export default {
     assignMismatchConfirm: 'Assegna comunque',
     assignPartialMismatchMessage: 'Il materiale della bobina "{{spoolMaterial}}" è simile ma non corrisponde esattamente a "{{trayMaterial}}" in {{location}}. Vuoi procedere?',
     assignProfileMismatchMessage: 'Il profilo della bobina "{{spoolProfile}}" non corrisponde al profilo del vassoio "{{trayProfile}}" in {{location}}. Vuoi procedere?',
-    // Spoolman filament catalog picker
-    spoolmanFilamentCatalog: 'Catalogo filamenti Spoolman',
-    pickFromSpoolmanCatalog: 'Scegli dal catalogo Spoolman…',
-    spoolmanFilamentSelected: 'Filamento selezionato dal catalogo Spoolman',
-    spoolmanFilamentUnlinked: 'Collegamento al catalogo filamenti rimosso',
-    noSpoolmanFilaments: 'Nessun filamento trovato nel catalogo Spoolman',
-    spoolmanFilamentColorSwatch: 'Colore del filamento',
-    spoolWeightManagedBySpoolman: 'Il peso della bobina vuota è gestito per tipo di filamento in Spoolman',
-    spoolmanCatalogLoadFailed: 'Impossibile caricare il catalogo Spoolman',
   },
 
   // Timelapse
@@ -5275,7 +5222,6 @@ export default {
       creating: 'Creazione...',
       spoolCreated: 'Bobina creata! Pronto per la scrittura.',
       createFailed: 'Impossibile creare la bobina',
-      incompleteDataWarning: 'Tag scritto con dati Spoolman incompleti',
     },
     quickMenu: {
       printerPower: 'Alimentazione stampante',

+ 7 - 61
frontend/src/i18n/locales/ja.ts

@@ -1604,18 +1604,6 @@ export default {
     amsSyncing: '同期中...',
     amsSyncSuccess: '{{synced}}個のスプールを同期、{{skipped}}個をスキップ',
     amsSyncError: 'AMSからの重量同期に失敗しました',
-    spoolmanAmsSyncButton: 'AMSからSpoolmanの重量を同期',
-    spoolmanAmsSyncTitle: 'AMSからSpoolmanスプール重量を同期',
-    spoolmanAmsSyncMessage: '接続されたプリンターの現在のAMS残量値に基づいて、すべてのSpoolmanスプール重量を更新します。プリンターがオンラインである必要があります。',
-    spoolmanAmsSyncing: '同期中...',
-    spoolmanAmsSyncSuccess: '{{synced}}個のSpoolmanスプールを同期、{{skipped}}個をスキップ',
-    spoolmanAmsSyncError: 'AMSからのSpoolman重量同期に失敗しました',
-    spoolmanAmsSyncErrorUnreachable: '同期失敗(Spoolmanに接続できません)',
-    spoolmanAmsSyncErrorNotConfigured: '同期失敗(Spoolmanが設定されていません)',
-    spoolmanNotConfigured: 'Spoolmanが設定されていません',
-    // Spoolman filament catalog section in spool catalog settings
-    spoolmanFilamentCatalogTitle: 'Spoolmanフィラメントカタログ',
-    spoolmanFilamentCatalogDesc: 'Spoolmanインスタンスのフィラメント名と風袋重量。名前とスプール重量はここで編集できます。その他の属性はSpoolmanで直接管理してください。',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'Spoolmanサーバーのurl(例:http://localhost:7912)',
@@ -1983,17 +1971,6 @@ export default {
       bulkDeleteConfirm: '{{count}}件のエントリーを削除してもよろしいですか?',
       bulkDeleted: '{{count}}件のエントリーを削除しました',
       bulkDeleteFailed: 'エントリーの削除に失敗しました',
-      material: '材料',
-      spoolWeight: 'スプール重量',
-      color: '色',
-      updateSpoolWeight: 'スプール重量を更新',
-      filamentUpdated: 'フィラメントを更新しました',
-      filamentUpdateFailed: 'フィラメントの更新に失敗しました',
-      filamentUpdateInvalid: 'フィラメントデータが無効です',
-      keepExistingSpoolWeight: '既存スプールの旧重量を保持',
-      keepExistingSpoolWeightDesc: 'このフィラメントタイプで既に作成されたスプールは旧風袋重量を保持します。新しいスプールは更新後の値を使用します。',
-      applyToAllSpools: '全スプールに適用',
-      applyToAllSpoolsDesc: 'このフィラメントタイプの全重量計算に新しい風袋重量が即座に適用されます。',
     },
     colorCatalog: {
       title: 'カラーカタログ',
@@ -3373,15 +3350,14 @@ export default {
     linkToSpoolman: 'Spoolmanに連携',
     openInSpoolman: 'Spoolmanで開く',
     unlinkSpool: 'スプールのリンクを解除',
-    unlinkConfirmTitle: 'スプールの割り当てを解除しますか?',
-    unlinkConfirmMessage: 'このスロットからスプールが削除されます。スプール自体のデータは変更されません。',
+    unlinkConfirmTitle: 'スプールのリンクを解除しますか?',
+    unlinkConfirmMessage: 'これにより、スプールがSpoolmanから切断されます。Spoolman内のスプールデータは変更されません。',
     selectSpool: 'スプールを選択',
-    noUnlinkedSpools: '割り当てられていないスプールがありません',
-    linkSuccess: 'スプールが正常に割り当てられました',
-    linkFailed: 'スプールの割り当てに失敗しました',
-    unlinkSuccess: 'スプールの割り当てが正常に解除されました',
-    unlinkFailed: 'スプールの割り当て解除に失敗しました',
-    linkedSpool: '割り当て済みスプール',
+    noUnlinkedSpools: 'Spoolmanに未連携のスプールが見つかりません。',
+    linkSuccess: 'スプールをSpoolmanにリンクしました',
+    linkFailed: 'スプールのリンクに失敗しました',
+    unlinkSuccess: 'スプールをSpoolmanから解除しました',
+    unlinkFailed: 'スプールのリンク解除に失敗しました',
     spoolId: 'スプールID',
     fillSourceLabel: '(Spoolman)',
     weight: '重量',
@@ -3458,9 +3434,6 @@ export default {
     measuredWeight: '計測重量',
     spoolName: 'スプール',
     costPerKg: 'kgあたりのコスト',
-    storageLocation: '保管場所',
-    storageLocationPlaceholder: '例:棚A、引き出し1',
-    openInInventory: 'インベントリで開く',
     measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
     slicerFilament: 'スライサーフィラメント',
     slicerFilamentName: 'スライサープリセット名',
@@ -3494,7 +3467,6 @@ export default {
     assigning: '割り当て中...',
     searchSpools: 'スプールを検索...',
     showAllSpools: 'すべてのスプールを表示',
-    spoolmanSpools: 'Spoolman スプール',
     allMaterials: 'すべての素材',
     filterByBrand: 'ブランドで絞り込み...',
     showArchived: 'アーカイブ済みを表示',
@@ -3503,27 +3475,11 @@ export default {
     stock: '在庫',
     configured: '設定済み',
     spoolsCreated: '{{count}}本のスプールを作成しました',
-    spoolsPartiallyCreated: '{{total}}本中{{created}}本のスプールを作成しました(一部失敗)',
     spoolCreated: 'スプールを作成しました',
     spoolUpdated: 'スプールを更新しました',
     spoolDeleted: 'スプールを削除しました',
-    deepLinkSpoolNotFound: 'スプールが見つかりません',
-    deepLinkFetchFailed: 'スプールを読み込めませんでした — もう一度お試しください',
     spoolArchived: 'スプールをアーカイブしました',
     spoolRestored: 'スプールを復元しました',
-    kProfileSaveFailed: 'Kプロファイル設定を保存できませんでした',
-    syncWeightSpoolNotFound: 'スプールが見つかりません — 削除された可能性があります',
-    syncWeightSpoolmanUnreachable: 'Spoolmanに接続できません — 後でもう一度お試しください',
-    syncWeightFailed: '重量の同期に失敗しました',
-    spoolmanUnreachable: 'Spoolmanに接続できません — 後でもう一度お試しください',
-    deleteSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
-    deleteFailed: 'スプールを削除できませんでした',
-    archiveSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
-    archiveFailed: 'スプールをアーカイブできませんでした',
-    restoreSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
-    restoreFailed: 'スプールを復元できませんでした',
-    saveFailed: '変更を保存できませんでした',
-    tagClearFailed: 'タグを削除できませんでした',
     deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
     archiveConfirm: 'このスプールをアーカイブしますか?',
     advancedSettings: '詳細設定',
@@ -3653,15 +3609,6 @@ export default {
     assignMismatchConfirm: '強制的に割り当て',
     assignPartialMismatchMessage: 'スプールの材料「{{spoolMaterial}}」は「{{trayMaterial}}」に似ていますが、{{location}} と完全には一致しません。続行しますか?',
     assignProfileMismatchMessage: 'スプールのプロファイル「{{spoolProfile}}」は {{location}} のトレイプロファイル「{{trayProfile}}」と一致しません。続行しますか?',
-    // Spoolman filament catalog picker
-    spoolmanFilamentCatalog: 'Spoolmanフィラメントカタログ',
-    pickFromSpoolmanCatalog: 'Spoolmanカタログから選択…',
-    spoolmanFilamentSelected: 'Spoolmanカタログからフィラメントを選択しました',
-    spoolmanFilamentUnlinked: 'フィラメントカタログのリンクを解除しました',
-    noSpoolmanFilaments: 'Spoolmanカタログにフィラメントが見つかりません',
-    spoolmanFilamentColorSwatch: 'フィラメントの色',
-    spoolWeightManagedBySpoolman: '空スプールの重量はSpoolmanでフィラメントタイプごとに管理されています',
-    spoolmanCatalogLoadFailed: 'Spoolmanのフィラメントカタログを読み込めませんでした',
   },
 
   // Timelapse
@@ -5288,7 +5235,6 @@ export default {
       creating: '作成中...',
       spoolCreated: 'スプール作成完了!書込み準備ができました。',
       createFailed: 'スプールの作成に失敗しました',
-      incompleteDataWarning: '不完全なSpoolmanデータでタグを書き込みました',
     },
     quickMenu: {
       printerPower: 'プリンター電源',

+ 7 - 61
frontend/src/i18n/locales/pt-BR.ts

@@ -1562,18 +1562,6 @@ export default {
     amsSyncing: 'Sincronizando...',
     amsSyncSuccess: '{{synced}} rolo(s) sincronizado(s), {{skipped}} ignorado(s)',
     amsSyncError: 'Falha ao sincronizar pesos do AMS',
-    spoolmanAmsSyncButton: 'Sincronizar pesos do Spoolman pelo AMS',
-    spoolmanAmsSyncTitle: 'Sincronizar pesos dos rolos Spoolman pelo AMS',
-    spoolmanAmsSyncMessage: 'Isso atualizará os pesos de todos os rolos Spoolman com base nos valores atuais de % restante do AMS das impressoras conectadas. As impressoras devem estar online.',
-    spoolmanAmsSyncing: 'Sincronizando...',
-    spoolmanAmsSyncSuccess: '{{synced}} rolo(s) Spoolman sincronizado(s), {{skipped}} ignorado(s)',
-    spoolmanAmsSyncError: 'Falha ao sincronizar pesos do Spoolman pelo AMS',
-    spoolmanAmsSyncErrorUnreachable: 'Falha na sincronização (Spoolman inacessível)',
-    spoolmanAmsSyncErrorNotConfigured: 'Falha na sincronização (Spoolman não configurado)',
-    spoolmanNotConfigured: 'Spoolman não configurado',
-    // Spoolman filament catalog section in spool catalog settings
-    spoolmanFilamentCatalogTitle: 'Catálogo de filamentos Spoolman',
-    spoolmanFilamentCatalogDesc: 'Nomes de filamentos e pesos tara da sua instância Spoolman. Nome e peso do carretel são editáveis aqui; todas as outras propriedades são gerenciadas diretamente no Spoolman.',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL do seu servidor Spoolman (por exemplo, http://localhost:7912)',
@@ -1941,17 +1929,6 @@ export default {
       bulkDeleteConfirm: 'Tem certeza de que deseja excluir {{count}} entradas?',
       bulkDeleted: '{{count}} entradas excluídas',
       bulkDeleteFailed: 'Falha ao excluir entradas',
-      material: 'Material',
-      spoolWeight: 'Peso do carretel',
-      color: 'Cor',
-      updateSpoolWeight: 'Atualizar peso do carretel',
-      filamentUpdated: 'Filamento atualizado',
-      filamentUpdateFailed: 'Falha ao atualizar filamento',
-      filamentUpdateInvalid: 'Dados de filamento inválidos',
-      keepExistingSpoolWeight: 'Manter peso antigo para carretéis existentes',
-      keepExistingSpoolWeightDesc: 'Os carretéis já criados com este tipo de filamento mantêm o peso tara antigo. Novos carretéis usam o valor atualizado.',
-      applyToAllSpools: 'Aplicar a todos os carretéis',
-      applyToAllSpoolsDesc: 'Todos os cálculos de peso para este tipo de filamento usam imediatamente o novo peso tara.',
     },
     colorCatalog: {
       title: 'Catálogo de Cores',
@@ -3360,15 +3337,14 @@ export default {
     linkToSpoolman: 'Vincular ao Spoolman',
     openInSpoolman: 'Abrir no Spoolman',
     unlinkSpool: 'Desvincular Carretel',
-    unlinkConfirmTitle: 'Remover atribuição do carretel?',
-    unlinkConfirmMessage: 'Isso removerá o carretel deste slot. Os dados do próprio carretel permanecerão inalterados.',
+    unlinkConfirmTitle: 'Desvincular carretel?',
+    unlinkConfirmMessage: 'Isso desconectará o carretel do Spoolman. Os dados do carretel no Spoolman permanecerão inalterados.',
     selectSpool: 'Selecionar Carretel',
-    noUnlinkedSpools: 'Nenhum carretel não atribuído disponível',
-    linkSuccess: 'Carretel atribuído com sucesso',
-    linkFailed: 'Falha ao atribuir carretel',
-    unlinkSuccess: 'Atribuição do carretel removida com sucesso',
-    unlinkFailed: 'Falha ao remover atribuição do carretel',
-    linkedSpool: 'Carretel atribuído',
+    noUnlinkedSpools: 'Nenhum carretel desvinculado disponível',
+    linkSuccess: 'Carretel vinculado ao Spoolman com sucesso',
+    linkFailed: 'Falha ao vincular carretel',
+    unlinkSuccess: 'Carretel desvinculado do Spoolman com sucesso',
+    unlinkFailed: 'Falha ao desvincular carretel',
     spoolId: 'Carretel ID (Spool ID)',
     fillSourceLabel: '(Spoolman)',
     weight: 'Peso',
@@ -3445,9 +3421,6 @@ export default {
     measuredWeight: 'Peso Medido',
     spoolName: 'Bobina',
     costPerKg: 'Custo por kg',
-    storageLocation: 'Local de armazenamento',
-    storageLocationPlaceholder: 'ex. Prateleira A, Gaveta 1',
-    openInInventory: 'Abrir no inventário',
     measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.',
     slicerFilament: 'Filamento do Fatiador',
     slicerFilamentName: 'Nome do Predefinido do Fatiador',
@@ -3481,7 +3454,6 @@ export default {
     assigning: 'Atribuindo...',
     searchSpools: 'Pesquisar carretéis...',
     showAllSpools: 'Mostrar todos os carretéis',
-    spoolmanSpools: 'Bobinas Spoolman',
     allMaterials: 'Todos os Materiais',
     filterByBrand: 'Filtrar por marca...',
     showArchived: 'Mostrar arquivados',
@@ -3490,27 +3462,11 @@ export default {
     stock: 'Estoque',
     configured: 'Configurado',
     spoolsCreated: '{{count}} carretéis criados',
-    spoolsPartiallyCreated: '{{created}} de {{total}} carretéis criados (alguns falharam)',
     spoolCreated: 'Carretel criado',
     spoolUpdated: 'Carretel atualizado',
     spoolDeleted: 'Carretel excluído',
-    deepLinkSpoolNotFound: 'Carretel não encontrado',
-    deepLinkFetchFailed: 'Não foi possível carregar o carretel — tente novamente',
     spoolArchived: 'Carretel arquivado',
     spoolRestored: 'Carretel restaurado',
-    kProfileSaveFailed: 'Não foi possível salvar as configurações do perfil K',
-    syncWeightSpoolNotFound: 'Carretel não encontrado — pode ter sido excluído',
-    syncWeightSpoolmanUnreachable: 'Spoolman está inacessível — tente novamente mais tarde',
-    syncWeightFailed: 'Falha ao sincronizar o peso',
-    spoolmanUnreachable: 'Spoolman está inacessível — tente novamente mais tarde',
-    deleteSpoolNotFound: 'Carretel não encontrado — pode já ter sido excluído',
-    deleteFailed: 'Falha ao excluir carretel',
-    archiveSpoolNotFound: 'Carretel não encontrado — pode já ter sido excluído',
-    archiveFailed: 'Falha ao arquivar carretel',
-    restoreSpoolNotFound: 'Carretel não encontrado — pode já ter sido excluído',
-    restoreFailed: 'Falha ao restaurar carretel',
-    saveFailed: 'Falha ao salvar as alterações',
-    tagClearFailed: 'Falha ao limpar a tag',
     deleteConfirm: 'Tem certeza de que deseja excluir este carretel? Esta ação não pode ser desfeita.',
     archiveConfirm: 'Tem certeza de que deseja arquivar este carretel?',
     advancedSettings: 'Configurações Avançadas',
@@ -3640,15 +3596,6 @@ export default {
     assignMismatchConfirm: 'Atribuir mesmo assim',
     assignPartialMismatchMessage: 'O material do carretel "{{spoolMaterial}}" é semelhante, mas não corresponde exatamente a "{{trayMaterial}}" em {{location}}. Deseja prosseguir?',
     assignProfileMismatchMessage: 'O perfil do carretel "{{spoolProfile}}" não corresponde ao perfil da bandeja "{{trayProfile}}" em {{location}}. Deseja prosseguir?',
-    // Spoolman filament catalog picker
-    spoolmanFilamentCatalog: 'Catálogo de filamentos Spoolman',
-    pickFromSpoolmanCatalog: 'Escolher do catálogo Spoolman…',
-    spoolmanFilamentSelected: 'Filamento selecionado do catálogo Spoolman',
-    spoolmanFilamentUnlinked: 'Vínculo com o catálogo de filamentos removido',
-    noSpoolmanFilaments: 'Nenhum filamento encontrado no catálogo Spoolman',
-    spoolmanFilamentColorSwatch: 'Cor do filamento',
-    spoolWeightManagedBySpoolman: 'O peso do carretel vazio é gerenciado por tipo de filamento no Spoolman',
-    spoolmanCatalogLoadFailed: 'Falha ao carregar catálogo de filamentos do Spoolman',
   },
 
   // Timelapse
@@ -5275,7 +5222,6 @@ export default {
       creating: 'Criando...',
       spoolCreated: 'Bobina criada! Pronto para gravar.',
       createFailed: 'Falha ao criar bobina',
-      incompleteDataWarning: 'Tag gravada com dados Spoolman incompletos',
     },
     quickMenu: {
       printerPower: 'Energia da impressora',

+ 7 - 61
frontend/src/i18n/locales/zh-CN.ts

@@ -1606,18 +1606,6 @@ export default {
     amsSyncing: '同步中...',
     amsSyncSuccess: '已同步 {{synced}} 个耗材,跳过 {{skipped}} 个',
     amsSyncError: '从 AMS 同步重量失败',
-    spoolmanAmsSyncButton: '从 AMS 同步 Spoolman 重量',
-    spoolmanAmsSyncTitle: '从 AMS 同步 Spoolman 耗材重量',
-    spoolmanAmsSyncMessage: '这将根据已连接打印机的当前 AMS 剩余百分比值更新所有 Spoolman 耗材重量。打印机必须在线。',
-    spoolmanAmsSyncing: '同步中...',
-    spoolmanAmsSyncSuccess: '已同步 {{synced}} 个 Spoolman 耗材,跳过 {{skipped}} 个',
-    spoolmanAmsSyncError: '从 AMS 同步 Spoolman 重量失败',
-    spoolmanAmsSyncErrorUnreachable: '同步失败(Spoolman 不可访问)',
-    spoolmanAmsSyncErrorNotConfigured: '同步失败(Spoolman 未配置)',
-    spoolmanNotConfigured: 'Spoolman 未配置',
-    // Spoolman filament catalog section in spool catalog settings
-    spoolmanFilamentCatalogTitle: 'Spoolman 耗材目录',
-    spoolmanFilamentCatalogDesc: '来自 Spoolman 的耗材名称和皮重。名称和线轴重量可在此处编辑;其他属性请直接在 Spoolman 中管理。',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'Spoolman 服务器的 URL(例如 http://localhost:7912)',
@@ -1985,17 +1973,6 @@ export default {
       bulkDeleteConfirm: '确定要删除 {{count}} 个条目吗?',
       bulkDeleted: '已删除 {{count}} 个条目',
       bulkDeleteFailed: '删除条目失败',
-      material: '材料',
-      spoolWeight: '线轴重量',
-      color: '颜色',
-      updateSpoolWeight: '更新线轴重量',
-      filamentUpdated: '耗材已更新',
-      filamentUpdateFailed: '更新耗材失败',
-      filamentUpdateInvalid: '耗材数据无效',
-      keepExistingSpoolWeight: '保留现有线轴的旧重量',
-      keepExistingSpoolWeightDesc: '已使用此耗材类型创建的线轴保留旧的皮重。新线轴使用更新后的值。',
-      applyToAllSpools: '应用到所有线轴',
-      applyToAllSpoolsDesc: '此耗材类型的所有重量计算立即使用新的皮重。',
     },
     colorCatalog: {
       title: '颜色目录',
@@ -3361,15 +3338,14 @@ export default {
     linkToSpoolman: '链接到 Spoolman',
     openInSpoolman: '在 Spoolman 中打开',
     unlinkSpool: '取消链接耗材',
-    unlinkConfirmTitle: '取消分配料盘?',
-    unlinkConfirmMessage: '这将从此插槽中移除料盘。料盘本身的数据将保持不变。',
+    unlinkConfirmTitle: '解开线轴?',
+    unlinkConfirmMessage: '这将断开卷轴与 Spoolman 的连接。Spoolman 中的卷轴数据将保持不变。',
     selectSpool: '选择耗材',
-    noUnlinkedSpools: '无未分配的料盘',
-    linkSuccess: '料盘分配成功',
-    linkFailed: '料盘分配失败',
-    unlinkSuccess: '料盘取消分配成功',
-    unlinkFailed: '料盘取消分配失败',
-    linkedSpool: '已分配料盘',
+    noUnlinkedSpools: '无未链接的耗材',
+    linkSuccess: '耗材已成功链接到 Spoolman',
+    linkFailed: '链接耗材失败',
+    unlinkSuccess: '已成功从 Spoolman 取消链接耗材',
+    unlinkFailed: '取消链接耗材失败',
     spoolId: '耗材 ID',
     fillSourceLabel: '(Spoolman)',
     weight: '重量',
@@ -3446,9 +3422,6 @@ export default {
     measuredWeight: '称量重量',
     spoolName: '线轴',
     costPerKg: '每公斤成本',
-    storageLocation: '存放位置',
-    storageLocationPlaceholder: '例如:货架A,抽屉1',
-    openInInventory: '在库存中查看',
     measuredWeightError: '称量重量必须在 {{min}}g 到 {{max}}g 之间。',
     slicerFilament: '切片耗材',
     slicerFilamentName: '切片预设名称',
@@ -3487,7 +3460,6 @@ export default {
     assigning: '分配中...',
     searchSpools: '搜索耗材...',
     showAllSpools: '显示所有耗材',
-    spoolmanSpools: 'Spoolman 线轴',
     allMaterials: '所有材料',
     filterByBrand: '按品牌筛选...',
     showArchived: '显示已归档',
@@ -3496,27 +3468,11 @@ export default {
     stock: '库存',
     configured: '已配置',
     spoolsCreated: '已创建 {{count}} 个耗材',
-    spoolsPartiallyCreated: '已创建 {{created}} / {{total}} 个耗材(部分失败)',
     spoolCreated: '耗材已创建',
     spoolUpdated: '耗材已更新',
     spoolDeleted: '耗材已删除',
-    deepLinkSpoolNotFound: '未找到耗材',
-    deepLinkFetchFailed: '无法加载耗材 — 请重试',
     spoolArchived: '耗材已归档',
     spoolRestored: '耗材已恢复',
-    kProfileSaveFailed: 'K值配置文件设置无法保存',
-    syncWeightSpoolNotFound: '未找到耗材 — 可能已被删除',
-    syncWeightSpoolmanUnreachable: 'Spoolman 无法访问 — 请稍后再试',
-    syncWeightFailed: '重量同步失败',
-    spoolmanUnreachable: 'Spoolman 无法访问 — 请稍后再试',
-    deleteSpoolNotFound: '未找到耗材 — 可能已被删除',
-    deleteFailed: '删除耗材失败',
-    archiveSpoolNotFound: '未找到耗材 — 可能已被删除',
-    archiveFailed: '归档耗材失败',
-    restoreSpoolNotFound: '未找到耗材 — 可能已被删除',
-    restoreFailed: '恢复耗材失败',
-    saveFailed: '保存更改失败',
-    tagClearFailed: '清除标签失败',
     deleteConfirm: '确定要删除此耗材吗?此操作无法撤销。',
     archiveConfirm: '确定要归档此耗材吗?',
     advancedSettings: '高级设置',
@@ -3641,15 +3597,6 @@ export default {
     historyCleared: '使用历史已清除',
     fillSourceLabel: '(库存)',
     lowStockThresholdError: '阈值必须在 0.1 到 99.9 之间',
-    // Spoolman filament catalog picker
-    spoolmanFilamentCatalog: 'Spoolman 耗材目录',
-    pickFromSpoolmanCatalog: '从 Spoolman 目录选择…',
-    spoolmanFilamentSelected: '已从 Spoolman 目录选择耗材',
-    spoolmanFilamentUnlinked: '已解除耗材目录关联',
-    noSpoolmanFilaments: 'Spoolman 目录中未找到耗材',
-    spoolmanFilamentColorSwatch: '耗材颜色',
-    spoolWeightManagedBySpoolman: '空线轴重量在 Spoolman 中按耗材类型管理',
-    spoolmanCatalogLoadFailed: '无法加载 Spoolman 耗材目录',
   },
 
   // Timelapse
@@ -5275,7 +5222,6 @@ export default {
       creating: '创建中...',
       spoolCreated: '耗材已创建!准备写入。',
       createFailed: '创建耗材失败',
-      incompleteDataWarning: '已使用不完整的Spoolman数据写入标签',
     },
     quickMenu: {
       printerPower: '打印机电源',

+ 7 - 61
frontend/src/i18n/locales/zh-TW.ts

@@ -1606,18 +1606,6 @@ export default {
     amsSyncing: '同步中...',
     amsSyncSuccess: '已同步 {{synced}} 個耗材,跳過 {{skipped}} 個',
     amsSyncError: '從 AMS 同步重量失敗',
-    spoolmanAmsSyncButton: '從 AMS 同步 Spoolman 重量',
-    spoolmanAmsSyncTitle: '從 AMS 同步 Spoolman 耗材重量',
-    spoolmanAmsSyncMessage: '這將根據已連線印表機的目前 AMS 剩餘百分比值更新所有 Spoolman 耗材重量。印表機必須線上。',
-    spoolmanAmsSyncing: '同步中...',
-    spoolmanAmsSyncSuccess: '已同步 {{synced}} 個 Spoolman 耗材,跳過 {{skipped}} 個',
-    spoolmanAmsSyncError: '從 AMS 同步 Spoolman 重量失敗',
-    spoolmanAmsSyncErrorUnreachable: '同步失敗(Spoolman 無法存取)',
-    spoolmanAmsSyncErrorNotConfigured: '同步失敗(Spoolman 未設定)',
-    spoolmanNotConfigured: 'Spoolman 未設定',
-    // Spoolman filament catalog section in spool catalog settings
-    spoolmanFilamentCatalogTitle: 'Spoolman 耗材目錄',
-    spoolmanFilamentCatalogDesc: '來自 Spoolman 的耗材名稱和皮重。名稱和線軸重量可在此處編輯;其他屬性請直接在 Spoolman 中管理。',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'Spoolman 伺服器的 URL(例如 http://localhost:7912)',
@@ -1985,17 +1973,6 @@ export default {
       bulkDeleteConfirm: '確定要刪除 {{count}} 個條目嗎?',
       bulkDeleted: '已刪除 {{count}} 個條目',
       bulkDeleteFailed: '刪除條目失敗',
-      material: '材料',
-      spoolWeight: '線軸重量',
-      color: '顏色',
-      updateSpoolWeight: '更新線軸重量',
-      filamentUpdated: '耗材已更新',
-      filamentUpdateFailed: '更新耗材失敗',
-      filamentUpdateInvalid: '耗材資料無效',
-      keepExistingSpoolWeight: '保留現有線軸的舊重量',
-      keepExistingSpoolWeightDesc: '已使用此耗材類型建立的線軸保留舊的皮重。新線軸使用更新後的值。',
-      applyToAllSpools: '套用至所有線軸',
-      applyToAllSpoolsDesc: '此耗材類型的所有重量計算立即使用新的皮重。',
     },
     colorCatalog: {
       title: '顏色目錄',
@@ -3361,15 +3338,14 @@ export default {
     linkToSpoolman: '連結到 Spoolman',
     openInSpoolman: '在 Spoolman 中開啟',
     unlinkSpool: '取消連結耗材',
-    unlinkConfirmTitle: '取消指派料盤?',
-    unlinkConfirmMessage: '這將從此插槽中移除料盤。料盤本身的資料將保持不變。',
+    unlinkConfirmTitle: '解開料盤?',
+    unlinkConfirmMessage: '這將斷開卷軸與 Spoolman 的連線。Spoolman 中的卷軸資料將保持不變。',
     selectSpool: '選擇耗材',
-    noUnlinkedSpools: '無未指派的料盤',
-    linkSuccess: '料盤指派成功',
-    linkFailed: '料盤指派失敗',
-    unlinkSuccess: '料盤取消指派成功',
-    unlinkFailed: '料盤取消指派失敗',
-    linkedSpool: '已指派料盤',
+    noUnlinkedSpools: '無未連結的耗材',
+    linkSuccess: '耗材已成功連結到 Spoolman',
+    linkFailed: '連結耗材失敗',
+    unlinkSuccess: '已成功從 Spoolman 取消連結耗材',
+    unlinkFailed: '取消連結耗材失敗',
     spoolId: '耗材 ID',
     fillSourceLabel: '(Spoolman)',
     weight: '重量',
@@ -3446,9 +3422,6 @@ export default {
     measuredWeight: '稱量重量',
     spoolName: '料盤',
     costPerKg: '每公斤成本',
-    storageLocation: '存放位置',
-    storageLocationPlaceholder: '例如:貨架A,抽屜1',
-    openInInventory: '在庫存中查看',
     measuredWeightError: '稱量重量必須在 {{min}}g 到 {{max}}g 之間。',
     slicerFilament: '切片耗材',
     slicerFilamentName: '切片預設名稱',
@@ -3487,7 +3460,6 @@ export default {
     assigning: '分配中...',
     searchSpools: '搜尋耗材...',
     showAllSpools: '顯示所有耗材',
-    spoolmanSpools: 'Spoolman 線軸',
     allMaterials: '所有材料',
     filterByBrand: '按品牌篩選...',
     showArchived: '顯示已歸檔',
@@ -3496,27 +3468,11 @@ export default {
     stock: '庫存',
     configured: '已設定',
     spoolsCreated: '已建立 {{count}} 個耗材',
-    spoolsPartiallyCreated: '已建立 {{created}} / {{total}} 個耗材(部分失敗)',
     spoolCreated: '耗材已建立',
     spoolUpdated: '耗材已更新',
     spoolDeleted: '耗材已刪除',
-    deepLinkSpoolNotFound: '找不到耗材',
-    deepLinkFetchFailed: '無法載入耗材 — 請重試',
     spoolArchived: '耗材已歸檔',
     spoolRestored: '耗材已恢復',
-    kProfileSaveFailed: 'K值設定檔設定無法儲存',
-    syncWeightSpoolNotFound: '找不到耗材 — 可能已被刪除',
-    syncWeightSpoolmanUnreachable: 'Spoolman 無法存取 — 請稍後再試',
-    syncWeightFailed: '重量同步失敗',
-    spoolmanUnreachable: 'Spoolman 無法存取 — 請稍後再試',
-    deleteSpoolNotFound: '找不到耗材 — 可能已被刪除',
-    deleteFailed: '刪除耗材失敗',
-    archiveSpoolNotFound: '找不到耗材 — 可能已被刪除',
-    archiveFailed: '歸檔耗材失敗',
-    restoreSpoolNotFound: '找不到耗材 — 可能已被刪除',
-    restoreFailed: '恢復耗材失敗',
-    saveFailed: '儲存變更失敗',
-    tagClearFailed: '清除標籤失敗',
     deleteConfirm: '確定要刪除此耗材嗎?此操作無法復原。',
     archiveConfirm: '確定要歸檔此耗材嗎?',
     advancedSettings: '進階設定',
@@ -3641,15 +3597,6 @@ export default {
     historyCleared: '使用歷史已清除',
     fillSourceLabel: '(庫存)',
     lowStockThresholdError: '閾值必須在 0.1 到 99.9 之間',
-    // Spoolman filament catalog picker
-    spoolmanFilamentCatalog: 'Spoolman 耗材目錄',
-    pickFromSpoolmanCatalog: '從 Spoolman 目錄選擇…',
-    spoolmanFilamentSelected: '已從 Spoolman 目錄選擇耗材',
-    spoolmanFilamentUnlinked: '已解除耗材目錄關聯',
-    noSpoolmanFilaments: 'Spoolman 目錄中未找到耗材',
-    spoolmanFilamentColorSwatch: '耗材顏色',
-    spoolWeightManagedBySpoolman: '空線軸重量在 Spoolman 中按耗材類型管理',
-    spoolmanCatalogLoadFailed: '無法載入 Spoolman 耗材目錄',
   },
 
   // Timelapse
@@ -5275,7 +5222,6 @@ export default {
       creating: '建立中...',
       spoolCreated: '耗材已建立!準備寫入。',
       createFailed: '建立耗材失敗',
-      incompleteDataWarning: '已使用不完整的Spoolman資料寫入標籤',
     },
     quickMenu: {
       printerPower: '印表機電源',

Some files were not shown because too many files changed in this diff