Procházet zdrojové kódy

Revert "feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)"

This reverts commit 89f14c57ad77338e6418ce636104b4028ce4cdfb.
maziggy před 1 měsícem
rodič
revize
9e938cbc8c
47 změnil soubory, kde provedl 341 přidání a 7233 odebrání
  1. 0 0
      CHANGELOG.md
  2. 0 222
      backend/app/api/routes/_spoolman_helpers.py
  3. 75 372
      backend/app/api/routes/spoolbuddy.py
  4. 0 531
      backend/app/api/routes/spoolman_inventory.py
  5. 2 44
      backend/app/core/auth.py
  6. 0 10
      backend/app/core/database.py
  7. 0 2
      backend/app/main.py
  8. 1 3
      backend/app/models/spool.py
  9. 0 1
      backend/app/models/spoolbuddy_device.py
  10. 3 5
      backend/app/schemas/spool.py
  11. 31 47
      backend/app/schemas/spoolbuddy.py
  12. 23 65
      backend/app/services/opentag3d.py
  13. 22 88
      backend/app/services/spoolbuddy_ssh.py
  14. 5 321
      backend/app/services/spoolman.py
  15. 2 10
      backend/app/services/spoolman_tracking.py
  16. 0 160
      backend/tests/integration/test_auth_apikey_rbac.py
  17. 2 1099
      backend/tests/integration/test_spoolbuddy.py
  18. 0 1524
      backend/tests/integration/test_spoolman_inventory_api.py
  19. 23 228
      backend/tests/unit/services/test_spoolbuddy_ssh.py
  20. 1 48
      backend/tests/unit/services/test_spoolman_service.py
  21. 3 7
      backend/tests/unit/services/test_spoolman_tracking.py
  22. 0 179
      backend/tests/unit/test_opentag3d.py
  23. 0 197
      backend/tests/unit/test_spoolbuddy_schema_validation.py
  24. 0 246
      backend/tests/unit/test_spoolman_inventory_helpers.py
  25. 0 424
      backend/tests/unit/test_spoolman_inventory_methods.py
  26. 0 223
      backend/tests/unit/test_spoolman_tracking.py
  27. 0 107
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  28. 0 233
      frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx
  29. 0 175
      frontend/src/__tests__/pages/InventoryPageSearch.test.ts
  30. 7 56
      frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx
  31. 0 81
      frontend/src/__tests__/utils/spoolFormValidation.test.ts
  32. 2 56
      frontend/src/api/client.ts
  33. 12 25
      frontend/src/components/FilamentHoverCard.tsx
  34. 38 95
      frontend/src/components/SpoolFormModal.tsx
  35. 0 13
      frontend/src/components/spool-form/AdditionalSection.tsx
  36. 2 9
      frontend/src/components/spool-form/types.ts
  37. 0 20
      frontend/src/i18n/locales/de.ts
  38. 0 20
      frontend/src/i18n/locales/en.ts
  39. 0 20
      frontend/src/i18n/locales/fr.ts
  40. 0 20
      frontend/src/i18n/locales/it.ts
  41. 0 20
      frontend/src/i18n/locales/ja.ts
  42. 0 20
      frontend/src/i18n/locales/pt-BR.ts
  43. 0 20
      frontend/src/i18n/locales/zh-CN.ts
  44. 0 20
      frontend/src/i18n/locales/zh-TW.ts
  45. 69 147
      frontend/src/pages/InventoryPage.tsx
  46. 17 12
      frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx
  47. 1 8
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


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

@@ -1,222 +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 logging
-import math
-import re
-from datetime import datetime, timezone
-from urllib.parse import urlparse
-
-logger = logging.getLogger(__name__)
-
-
-def assert_safe_spoolman_url(url: str) -> None:
-    """Raise ValueError if *url* should be blocked as an SSRF risk.
-
-    Checks performed:
-    - Scheme must be http or https.
-    - 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 IP-range
-      guard, but libc resolves them as valid IPv4 addresses.
-    - Bare numeric IP hosts in loopback (127.x, ::1), link-local (169.254.x,
-      fe80::), private (RFC-1918), multicast (224.x, ff::/8), or unspecified
-      (0.0.0.0, ::) ranges are rejected.
-    - IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped to their IPv4
-      equivalent and subject to the same checks.
-
-    Hostname-based addresses ("localhost", "internal.corp") require DNS resolution
-    and are outside the scope of this guard — they are mitigated by network-level
-    controls in the deployment environment.  "localhost" is intentionally *not*
-    blocked here because running Spoolman on the same host is a common and
-    supported topology.
-    """
-    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/).  Python's ipaddress.ip_address() raises ValueError for
-    # these forms so they slip past the except-clause below, but the C library
-    # (and browsers) parse them as valid IPv4 addresses.
-    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 — hostname-based addresses are out of scope.
-        return
-
-    # Unwrap IPv4-mapped IPv6 (::ffff:169.254.x.x etc.) so their IPv4
-    # properties are evaluated correctly.
-    effective: ipaddress.IPv4Address | ipaddress.IPv6Address = addr
-    if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
-        effective = addr.ipv4_mapped
-
-    if (
-        effective.is_loopback
-        or effective.is_link_local
-        or effective.is_private
-        or effective.is_multicast
-        or effective.is_unspecified
-    ):
-        raise ValueError(
-            "Spoolman URL must not point to a private, loopback, link-local, 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 _map_spoolman_spool(spool: dict) -> dict:
-    """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")
-        if not archived_at:
-            archived_at = datetime.now(timezone.utc).isoformat()
-
-    created_at: str = spool.get("registered") or datetime.now(timezone.utc).isoformat()
-
-    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(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,
-        # slicer_filament_name carries the Spoolman filament name for display
-        "slicer_filament": None,
-        "slicer_filament_name": 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": [],
-    }

+ 75 - 372
backend/app/api/routes/spoolbuddy.py

@@ -7,7 +7,6 @@ import time
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 from urllib.parse import urlparse
 from urllib.parse import urlparse
 
 
-import httpx
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -35,12 +34,10 @@ from backend.app.schemas.spoolbuddy import (
     TagRemovedRequest,
     TagRemovedRequest,
     TagScannedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
     UpdateSpoolWeightRequest,
-    UpdateStatusRequest,
     WriteTagRequest,
     WriteTagRequest,
     WriteTagResultRequest,
     WriteTagResultRequest,
 )
 )
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
 from backend.app.services.spool_tag_matcher import get_spool_by_tag
-from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -52,45 +49,6 @@ _spoolbuddy_online_last_broadcast: dict[str, float] = {}
 _diagnostic_results: dict[tuple[str, str], dict] = {}
 _diagnostic_results: dict[tuple[str, str], dict] = {}
 
 
 
 
-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 and private/loopback/link-local/multicast IPs.
-    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,
-        )
-        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:
 def _is_online(device: SpoolBuddyDevice) -> bool:
     if not device.last_seen:
     if not device.last_seen:
         return False
         return False
@@ -213,8 +171,8 @@ async def register_device(
         from backend.app.services.spoolbuddy_ssh import get_public_key
         from backend.app.services.spoolbuddy_ssh import get_public_key
 
 
         response.ssh_public_key = await 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
     return response
 
 
@@ -363,89 +321,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}
+        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,
+        )
 
 
-    # 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,
-                        "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,
-            )
-            # Degrade gracefully on any Spoolman connectivity failure — device must not receive 500.
-            # Also suppresses unknown_tag broadcast: the UI cannot distinguish a Spoolman outage
-            # from "spool not registered", which would trigger duplicate-registration flows.
-            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,
-            )
-            # 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}
-
-    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,
-    )
-    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")
 @router.post("/nfc/tag-removed")
@@ -471,95 +368,38 @@ async def nfc_write_tag(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Queue an NFC tag write command for a SpoolBuddy device."""
     """Queue an NFC tag write command for a SpoolBuddy device."""
+    import json
+
     from backend.app.models.spool import Spool
     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))
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
     device = result.scalar_one_or_none()
     device = result.scalar_one_or_none()
     if not device:
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
         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")
-
-        try:
-            sm_spool = await sm_client.get_spool(req.spool_id)
-        except SpoolmanNotFoundError:
-            raise HTTPException(status_code=404, detail="Spool not found")
-        except SpoolmanUnavailableError:
-            raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
-
-        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
     # Store write payload and set pending command
     device.pending_write_payload = json.dumps(
     device.pending_write_payload = json.dumps(
         {
         {
-            "spool_id": req.spool_id,
+            "spool_id": spool.id,
             "ndef_data_hex": ndef_data.hex(),
             "ndef_data_hex": ndef_data.hex(),
-            "data_origin": data_origin,
         }
         }
     )
     )
     device.pending_command = "write_tag"
     device.pending_command = "write_tag"
     await db.commit()
     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")
 @router.post("/nfc/write-result")
@@ -569,137 +409,37 @@ async def nfc_write_result(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Handle NFC tag write result from SpoolBuddy daemon."""
     """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))
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
     device = result.scalar_one_or_none()
     device = result.scalar_one_or_none()
     if not device:
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
         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_command = None
     device.pending_write_payload = None
     device.pending_write_payload = None
 
 
     if req.success:
     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:
-                logger.error(
-                    "Spoolman spool %d deleted before tag write-back could be persisted",
-                    req.spool_id,
-                )
-                # fall through to broadcast + raise 502 below
-            except SpoolmanUnavailableError:
-                logger.error(
-                    "Spoolman unreachable during tag write-back for spool %d",
-                    req.spool_id,
-                )
-                # 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_uid = req.tag_uid.upper()
             spool.tag_type = "ntag"
             spool.tag_type = "ntag"
             spool.data_origin = "opentag3d"
             spool.data_origin = "opentag3d"
             spool.encode_time = datetime.now(timezone.utc)
             spool.encode_time = datetime.now(timezone.utc)
             logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
             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:
     else:
         await db.commit()
         await db.commit()
         await ws_manager.broadcast(
         await ws_manager.broadcast(
@@ -764,47 +504,10 @@ async def update_spool_weight(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Update spool's used weight from scale reading."""
     """Update spool's used weight from scale reading."""
-    from backend.app.api.routes._spoolman_helpers import _safe_float
-
-    sm_client = await _get_spoolman_client_or_none(db)
-    if sm_client is not None:
-        try:
-            sm_spool = await sm_client.get_spool(req.spool_id)
-        except SpoolmanNotFoundError:
-            raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
-        except SpoolmanUnavailableError:
-            raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
-
-        filament = sm_spool.get("filament") or {}
-        raw_spool_weight = filament.get("spool_weight")
-        if not raw_spool_weight:
-            logger.warning(
-                "Spoolman spool %d has no spool_weight set; using 250g fallback for tare",
-                req.spool_id,
-            )
-        core_weight = _safe_float(raw_spool_weight, 250.0)
-        label_weight = _safe_float(filament.get("weight"), 1000.0)
-        remaining_weight = max(0.0, req.weight_grams - core_weight)
-
-        result = await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
-        if result is None:
-            raise HTTPException(status_code=502, detail="Failed to update spool weight in Spoolman")
-
-        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,
-            req.weight_grams,
-            core_weight,
-            remaining_weight,
-        )
-        return {"status": "ok", "weight_used": weight_used}
-
-    # Local DB mode
     from backend.app.models.spool import Spool
     from backend.app.models.spool import Spool
 
 
-    db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = db_result.scalar_one_or_none()
+    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
+    spool = result.scalar_one_or_none()
     if not spool:
     if not spool:
         raise HTTPException(status_code=404, detail="Spool not found")
         raise HTTPException(status_code=404, detail="Spool not found")
 
 
@@ -1256,14 +959,13 @@ async def get_ssh_public_key(
         key = await get_public_key()
         key = await get_public_key()
         return {"public_key": key}
         return {"public_key": key}
     except Exception as e:
     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")
 @router.post("/devices/{device_id}/update-status")
 async def report_update_status(
 async def report_update_status(
     device_id: str,
     device_id: str,
-    req: UpdateStatusRequest,
+    req: dict,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
@@ -1273,24 +975,25 @@ async def report_update_status(
     if not device:
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
         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"}
     return {"status": "ok"}
 
 

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

@@ -1,531 +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 logging
-import re
-import time
-from contextlib import asynccontextmanager
-
-from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response
-from fastapi.responses import JSONResponse
-from pydantic import BaseModel, Field, field_validator, model_validator
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from backend.app.api.routes._spoolman_helpers import (
-    _map_spoolman_spool,
-    _safe_float,
-    _safe_int,
-    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.settings import Settings
-from backend.app.models.user import User
-from backend.app.services.spoolman import (
-    SpoolmanClient,
-    SpoolmanClientError,
-    SpoolmanNotFoundError,
-    SpoolmanUnavailableError,
-    get_spoolman_client,
-    init_spoolman_client,
-)
-
-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 or empty string)."""
-    return val is None or val == ""
-
-
-async def _get_client(db: AsyncSession) -> SpoolmanClient:
-    """Return an authenticated Spoolman client 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 and bare private/loopback/link-local/multicast IPs.
-    # 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 exceptions to HTTP responses for all inventory endpoints.
-
-    Maps SpoolmanNotFoundError → 404 and SpoolmanUnavailableError → 503.
-    Add new SpoolmanClient exception mappings here rather than in individual handlers.
-    """
-    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=exc.status_code, 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 _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> dict:
-    """Patch the spool's price via a follow-up update when cost_per_kg is provided.
-
-    Bambuddy's SpoolmanClient.create_spool() does not forward price to Spoolman's POST /spool
-    endpoint, so a follow-up PATCH via update_spool_full is needed to set it.
-    On failure, logs an error and returns the original spool (caller gets HTTP 200 without price).
-    """
-    if cost_per_kg is None:
-        return spool
-    try:
-        async with _translate_spoolman_errors():
-            return await client.update_spool_full(spool["id"], price=cost_per_kg)
-    except HTTPException:
-        logger.error(
-            "Price update failed for spool %d; spool created without price (cost_per_kg=%s)",
-            spool["id"],
-            cost_per_kg,
-        )
-        return spool
-
-
-# ---------------------------------------------------------------------------
-# 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):
-    material: str = Field(..., 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)
-    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)
-
-    @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:
-        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)
-    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)
-
-    @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) -> 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)
-
-
-# ---------------------------------------------------------------------------
-# 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)
-    result = []
-    for s in spools:
-        try:
-            result.append(_map_spoolman_spool(s))
-        except ValueError as exc:
-            logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc)
-    return result
-
-
-@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:
-        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")
-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)
-
-    color_hex = (data.rgba or "808080FF")[:6]
-    async with _translate_spoolman_errors():
-        filament_id = 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,
-        )
-    if not filament_id:
-        raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
-
-    remaining = max(0.0, data.label_weight - data.weight_used)
-    spool = await client.create_spool(
-        filament_id=filament_id,
-        remaining_weight=remaining,
-        comment=data.note or None,
-        location=data.storage_location or None,
-    )
-    if not spool:
-        raise HTTPException(status_code=500, detail="Failed to create spool in Spoolman")
-
-    spool = await _apply_price_if_set(client, spool, data.cost_per_kg)
-    return _map_spoolman_spool(spool)
-
-
-@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
-
-    color_hex = (data.rgba or "808080FF")[:6]
-    async with _translate_spoolman_errors():
-        filament_id = 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,
-        )
-    if not filament_id:
-        raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
-
-    remaining = max(0.0, data.label_weight - data.weight_used)
-    created: list[dict] = []
-    for _ in range(payload.quantity):
-        spool = await client.create_spool(
-            filament_id=filament_id,
-            remaining_weight=remaining,
-            comment=data.note or None,
-            location=data.storage_location or None,
-        )
-        if spool:
-            spool = await _apply_price_if_set(client, spool, data.cost_per_kg)
-            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),
-            },
-        )
-
-    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 current.get("location")
-
-    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.
-    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.pop("tag", None)
-            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,
-            )
-
-    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 - filament.spool_weight (empty-spool
-    weight from Spoolman; falls back to 250 g when unset) and updates
-    Spoolman accordingly.
-    """
-    client = await _get_client(db)
-
-    async with _translate_spoolman_errors():
-        current = await client.get_spool(spool_id)
-
-    cur_filament = current.get("filament") or {}
-    core_weight = _safe_float(cur_filament.get("spool_weight"), 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}

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

@@ -25,45 +25,6 @@ from backend.app.models.user import User
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-# Permissions that cannot be accessed via API key.
-# API keys are limited to device/inventory operations. The following capabilities
-# are restricted to fully-authenticated users: user/group/API-key management,
-# settings read/backup/restore, firmware updates, and GitHub-backed backup/restore.
-_APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
-    {
-        Permission.SETTINGS_READ,
-        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
 # Password hashing
 # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
 # 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
 # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
@@ -670,7 +631,8 @@ async def get_api_key(
             detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <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
     key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value
     result = await db.execute(
     result = await db.execute(
         select(APIKey).where(
         select(APIKey).where(
@@ -794,7 +756,6 @@ def require_permission(*permissions: str | Permission):
             if x_api_key:
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
                     return None  # API key valid, allow access
 
 
             credentials_exception = HTTPException(
             credentials_exception = HTTPException(
@@ -811,7 +772,6 @@ def require_permission(*permissions: str | Permission):
             if token.startswith("bb_"):
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 api_key = await _validate_api_key(db, token)
                 if api_key:
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
                     return None  # API key valid, allow access
                 raise HTTPException(
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -877,7 +837,6 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
             if x_api_key:
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
                     return None  # API key valid, allow access
                     return None  # API key valid, allow access
 
 
             # Check for Bearer token (could be JWT or API key)
             # Check for Bearer token (could be JWT or API key)
@@ -887,7 +846,6 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     api_key = await _validate_api_key(db, token)
                     if api_key:
                     if api_key:
-                        _check_apikey_permissions(perm_strings)
                         return None  # API key valid, allow access
                         return None  # API key valid, allow access
                     raise HTTPException(
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         status_code=status.HTTP_401_UNAUTHORIZED,

+ 0 - 10
backend/app/core/database.py

@@ -1107,13 +1107,6 @@ async def run_migrations(conn):
 
 
     # Migration: Add cost tracking fields to spool table
     # Migration: Add cost tracking fields to spool table
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL")
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL")
-
-    # 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).
-    # SQLite ignores VARCHAR sizes; this is a no-op there but needed for PostgreSQL.
-    await _safe_execute(conn, "ALTER TABLE spool ALTER COLUMN tag_uid TYPE VARCHAR(32)")
     # Migration: Add cost field to spool_usage_history table
     # Migration: Add cost field to spool_usage_history table
     await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
     await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
     # Migration: Add archive_id field to spool_usage_history table
     # Migration: Add archive_id field to spool_usage_history table
@@ -1213,9 +1206,6 @@ async def run_migrations(conn):
     # Migration: Add system_stats JSON blob column to spoolbuddy_devices
     # Migration: Add system_stats JSON blob column to spoolbuddy_devices
     await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT")
     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
     # 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.
     # 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
     # PostgreSQL gets the correct schema from create_all(), so skip this

+ 0 - 2
backend/app/main.py

@@ -50,7 +50,6 @@ from backend.app.api.routes import (
     smart_plugs,
     smart_plugs,
     spoolbuddy,
     spoolbuddy,
     spoolman,
     spoolman,
-    spoolman_inventory,
     support,
     support,
     system,
     system,
     updates,
     updates,
@@ -4544,7 +4543,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(notification_templates.router, prefix=app_settings.api_prefix)
 app.include_router(user_notifications.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.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(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.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)
 app.include_router(camera.router, prefix=app_settings.api_prefix)

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

@@ -36,11 +36,9 @@ class Spool(Base):
     # Cost tracking
     # Cost tracking
     cost_per_kg: Mapped[float | None] = mapped_column(Float)  # Cost per kilogram
     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
     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
     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)
     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
     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.
     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)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     system_stats: Mapped[str | None] = mapped_column(Text, nullable=True)
     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())
     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())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 3 - 5
backend/app/schemas/spool.py

@@ -18,7 +18,7 @@ class SpoolBase(BaseModel):
     nozzle_temp_min: int | None = None
     nozzle_temp_min: int | None = None
     nozzle_temp_max: int | None = None
     nozzle_temp_max: int | None = None
     note: str | None = None
     note: str | None = None
-    tag_uid: str | None = Field(default=None, max_length=32)
+    tag_uid: str | None = None
     tray_uuid: str | None = None
     tray_uuid: str | None = None
     data_origin: str | None = None
     data_origin: str | None = None
     tag_type: str | None = None
     tag_type: str | None = None
@@ -26,7 +26,6 @@ class SpoolBase(BaseModel):
     weight_locked: bool = False
     weight_locked: bool = False
     last_scale_weight: int | None = None
     last_scale_weight: int | None = None
     last_weighed_at: datetime | None = None
     last_weighed_at: datetime | None = None
-    storage_location: str | None = Field(default=None, max_length=255)
 
 
 
 
 class SpoolCreate(SpoolBase):
 class SpoolCreate(SpoolBase):
@@ -53,13 +52,12 @@ class SpoolUpdate(BaseModel):
     nozzle_temp_min: int | None = None
     nozzle_temp_min: int | None = None
     nozzle_temp_max: int | None = None
     nozzle_temp_max: int | None = None
     note: str | None = None
     note: str | None = None
-    tag_uid: str | None = Field(default=None, max_length=32)
+    tag_uid: str | None = None
     tray_uuid: str | None = None
     tray_uuid: str | None = None
     data_origin: str | None = None
     data_origin: str | None = None
     tag_type: str | None = None
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     cost_per_kg: float | None = Field(default=None, ge=0)
     weight_locked: bool | None = None
     weight_locked: bool | None = None
-    storage_location: str | None = Field(default=None, max_length=255)
 
 
 
 
 class SpoolKProfileBase(BaseModel):
 class SpoolKProfileBase(BaseModel):
@@ -92,7 +90,7 @@ class SpoolResponse(SpoolBase):
     added_full: bool | None = None
     added_full: bool | None = None
     last_used: datetime | None = None
     last_used: datetime | None = None
     encode_time: datetime | None = None
     encode_time: datetime | None = None
-    tag_uid: str | None = Field(default=None, max_length=32)
+    tag_uid: str | None = None
     tray_uuid: str | None = None
     tray_uuid: str | None = None
     data_origin: str | None = None
     data_origin: str | None = None
     tag_type: str | None = None
     tag_type: str | None = None

+ 31 - 47
backend/app/schemas/spoolbuddy.py

@@ -1,8 +1,6 @@
-import json
 from datetime import datetime
 from datetime import datetime
-from typing import Literal
 
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field
 
 
 # --- Device schemas ---
 # --- Device schemas ---
 
 
@@ -11,14 +9,14 @@ class DeviceRegisterRequest(BaseModel):
     device_id: str = Field(..., min_length=1, max_length=50)
     device_id: str = Field(..., min_length=1, max_length=50)
     hostname: str = Field(..., min_length=1, max_length=100)
     hostname: str = Field(..., min_length=1, max_length=100)
     ip_address: str = Field(..., min_length=1, max_length=45)
     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_nfc: bool = True
     has_scale: bool = True
     has_scale: bool = True
     tare_offset: int = 0
     tare_offset: int = 0
     calibration_factor: float = 1.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
     has_backlight: bool = False
 
 
 
 
@@ -60,20 +58,13 @@ class HeartbeatRequest(BaseModel):
     nfc_ok: bool = False
     nfc_ok: bool = False
     scale_ok: bool = False
     scale_ok: bool = False
     uptime_s: int = 0
     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
     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):
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
     pending_command: str | None = None
@@ -89,31 +80,31 @@ class HeartbeatResponse(BaseModel):
 
 
 
 
 class TagScannedRequest(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
     sak: int | None = None
-    tag_type: str | None = Field(None, max_length=50)
+    tag_type: str | None = None
     raw_blocks: dict | None = None
     raw_blocks: dict | None = None
 
 
 
 
 class TagRemovedRequest(BaseModel):
 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 ---
 # --- Scale schemas ---
 
 
 
 
 class ScaleReadingRequest(BaseModel):
 class ScaleReadingRequest(BaseModel):
-    device_id: str = Field(..., max_length=50)
-    weight_grams: float = Field(..., ge=0.0, le=100_000.0)
+    device_id: str
+    weight_grams: float
     stable: bool = False
     stable: bool = False
     raw_adc: int | None = None
     raw_adc: int | None = None
 
 
 
 
 class UpdateSpoolWeightRequest(BaseModel):
 class UpdateSpoolWeightRequest(BaseModel):
-    spool_id: int = Field(..., gt=0)
+    spool_id: int
     weight_grams: float
     weight_grams: float
 
 
 
 
@@ -139,16 +130,16 @@ class CalibrationResponse(BaseModel):
 
 
 
 
 class WriteTagRequest(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):
 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
     success: bool
-    message: str | None = Field(None, max_length=500)
+    message: str | None = None
 
 
 
 
 class DisplaySettingsRequest(BaseModel):
 class DisplaySettingsRequest(BaseModel):
@@ -162,27 +153,20 @@ class SystemConfigRequest(BaseModel):
 
 
 
 
 class SystemCommandRequest(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):
 class SystemCommandResultRequest(BaseModel):
-    command: str = Field(..., max_length=50)
+    command: str
     success: bool
     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 ---
 # --- Diagnostics schemas ---
 
 
 
 
 class DiagnosticResultRequest(BaseModel):
 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
     success: bool
-    output: str = Field(..., max_length=10_000)
-    exit_code: int = Field(..., ge=-255, le=255)
+    output: str
+    exit_code: int

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

@@ -13,7 +13,6 @@ NDEF structure:
 """
 """
 
 
 import struct
 import struct
-from typing import TypedDict
 
 
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 
 
@@ -22,50 +21,33 @@ PAYLOAD_SIZE = 102
 TAG_VERSION = 1000  # v1.000
 TAG_VERSION = 1000  # v1.000
 
 
 
 
-class MappedSpoolFields(TypedDict, total=False):
-    """Fields consumed by the OpenTag3D encoder from a mapped Spoolman spool dict."""
-
-    material: str | None
-    subtype: str | None
-    brand: str | None
-    color_name: str | None
-    rgba: str | None
-    label_weight: int | None
-    nozzle_temp_min: int | None
-
-
-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)
     buf = bytearray(PAYLOAD_SIZE)
 
 
     # 0x00: Tag Version (2 bytes, big-endian)
     # 0x00: Tag Version (2 bytes, big-endian)
     struct.pack_into(">H", buf, 0x00, TAG_VERSION)
     struct.pack_into(">H", buf, 0x00, TAG_VERSION)
 
 
     # 0x02: Base Material (5 bytes, UTF-8, space-padded)
     # 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]
     buf[0x02:0x07] = material.encode("utf-8")[:5]
 
 
     # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded)
     # 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]
     buf[0x07:0x0C] = modifiers.encode("utf-8")[:5]
 
 
     # 0x0C: Reserved (15 bytes, zero-fill) — already zero
     # 0x0C: Reserved (15 bytes, zero-fill) — already zero
 
 
     # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded)
     # 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]
     buf[0x1B:0x2B] = brand.encode("utf-8")[:16]
 
 
     # 0x2B: Color Name (32 bytes, UTF-8, space-padded)
     # 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]
     buf[0x2B:0x4B] = color_name.encode("utf-8")[:32]
 
 
     # 0x4B: Color 1 RGBA (4 bytes)
     # 0x4B: Color 1 RGBA (4 bytes)
-    rgba_hex = data.get("rgba") or "00000000"
+    rgba_hex = spool.rgba or "00000000"
     try:
     try:
         rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
         rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0"))
     except ValueError:
     except ValueError:
@@ -77,12 +59,11 @@ def _build_payload_from_dict(data: dict) -> bytes:
     # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
     # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm
     struct.pack_into(">H", buf, 0x5C, 1750)
     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
     # 0x61: Bed Temp (1 byte) — not tracked
     # 0x62: Density (2 bytes) — not tracked
     # 0x62: Density (2 bytes) — not tracked
@@ -92,26 +73,17 @@ def _build_payload_from_dict(data: dict) -> bytes:
     return bytes(buf)
     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
     mime_type = OPENTAG3D_MIME_TYPE
 
 
     # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
     # 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)])
     record_header = bytes([0xD2, len(mime_type), len(payload)])
     ndef_record = record_header + mime_type + payload
     ndef_record = record_header + mime_type + payload
 
 
@@ -122,24 +94,10 @@ def _encode_ndef(payload: bytes) -> bytes:
     else:
     else:
         tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
         tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
 
 
+    # Capability Container (page 4)
     cc = bytes([0xE1, 0x10, 0x12, 0x00])
     cc = bytes([0xE1, 0x10, 0x12, 0x00])
-    terminator = bytes([0xFE])
-    return cc + tlv + ndef_record + terminator
-
-
-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.
+    # Terminator TLV
+    terminator = bytes([0xFE])
 
 
-    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

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

@@ -15,7 +15,6 @@ entries for root). asyncssh does all of its work in-process.
 import asyncio
 import asyncio
 import logging
 import logging
 import os
 import os
-import shlex
 from pathlib import Path
 from pathlib import Path
 
 
 import asyncssh
 import asyncssh
@@ -140,61 +139,45 @@ async def _run_ssh_command(
     ip: str,
     ip: str,
     command: str,
     command: str,
     private_key: Path,
     private_key: Path,
-    *,
-    known_hosts: "asyncssh.SSHKnownHosts | None" = None,
     timeout: int = 60,
     timeout: int = 60,
-) -> tuple[int, str, str, str | None]:
+) -> tuple[int, str, str]:
     """Execute a command on a SpoolBuddy device via SSH.
     """Execute a command on a SpoolBuddy device via SSH.
 
 
     Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring
     Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring
     for the Docker/PUID rationale.
     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:
     try:
         async with asyncio.timeout(timeout):
         async with asyncio.timeout(timeout):
             async with asyncssh.connect(
             async with asyncssh.connect(
                 host=ip,
                 host=ip,
                 username=SSH_USER,
                 username=SSH_USER,
                 client_keys=[str(private_key)],
                 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
                 config=[],  # do not load ~/.ssh/config — HOME may not resolve under arbitrary Docker PUIDs
                 connect_timeout=10,
                 connect_timeout=10,
             ) as conn:
             ) 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)
                 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:
     except TimeoutError:
-        return -1, "", "SSH command timed out", None
+        return -1, "", "SSH command timed out"
     except (asyncssh.Error, OSError) as exc:
     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")
     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")
     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
     # 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
     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:
 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.
     """SSH into a SpoolBuddy device and update it to match Bambuddy's branch.
 
 
     Updates device.update_status/update_message in the DB and broadcasts
     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
     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
     install_path = install_path or DEFAULT_INSTALL_PATH
     branch = detect_current_branch()
     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:
     async def _update_progress(status: str, message: str) -> None:
         """Update device status in DB and broadcast via WebSocket."""
         """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
         # Step 1: Test SSH connectivity
         await _update_progress("updating", "Connecting via SSH...")
         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:
         if rc != 0:
             await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
             await _update_progress("error", f"SSH connection failed: {stderr[:200]}")
             return
             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
         # Step 2: Git fetch
         await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
         await _update_progress("updating", f"Fetching latest code (branch: {branch})...")
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
             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,
             private_key,
-            known_hosts=known_hosts,
             timeout=120,
             timeout=120,
         )
         )
         if rc != 0:
         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
         # Step 3: Git checkout + reset
         await _update_progress("updating", "Applying update...")
         await _update_progress("updating", "Applying update...")
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
             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,
             private_key,
-            known_hosts=known_hosts,
         )
         )
         if rc != 0:
         if rc != 0:
             await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}")
             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
         # Step 4: Install dependencies
         await _update_progress("updating", "Installing 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,
             ip_address,
             f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
             f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1",
             private_key,
             private_key,
-            known_hosts=known_hosts,
             timeout=120,
             timeout=120,
         )
         )
         if rc != 0:
         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
         # Step 5: Restart daemon
         await _update_progress("updating", "Restarting daemon...")
         await _update_progress("updating", "Restarting daemon...")
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
             ip_address,
             "sudo /usr/bin/systemctl restart spoolbuddy.service",
             "sudo /usr/bin/systemctl restart spoolbuddy.service",
             private_key,
             private_key,
-            known_hosts=known_hosts,
         )
         )
         if rc != 0:
         if rc != 0:
             await _update_progress("error", f"Service restart failed: {stderr[:200]}")
             await _update_progress("error", f"Service restart failed: {stderr[:200]}")
@@ -336,19 +272,17 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str
             ip_address,
             ip_address,
             "sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true",
             "sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true",
             private_key,
             private_key,
-            known_hosts=known_hosts,
         )
         )
-        rc, _, stderr, _ = await _run_ssh_command(
+        rc, _, stderr = await _run_ssh_command(
             ip_address,
             ip_address,
             "sudo /usr/bin/systemctl restart getty@tty1.service",
             "sudo /usr/bin/systemctl restart getty@tty1.service",
             private_key,
             private_key,
-            known_hosts=known_hosts,
         )
         )
         if rc != 0:
         if rc != 0:
             logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200])
             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)
         logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, 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]}")

+ 5 - 321
backend/app/services/spoolman.py

@@ -4,7 +4,6 @@ import asyncio
 import logging
 import logging
 from dataclasses import dataclass
 from dataclasses import dataclass
 from datetime import datetime, timezone
 from datetime import datetime, timezone
-from typing import Literal
 
 
 import httpx
 import httpx
 
 
@@ -57,25 +56,6 @@ class AMSTray:
     tray_weight: int  # Spool weight in grams (usually 1000)
     tray_weight: int  # Spool weight in grams (usually 1000)
 
 
 
 
-class SpoolmanNotFoundError(Exception):
-    """Raised when a spool ID does not exist in Spoolman (HTTP 404)."""
-
-
-class SpoolmanUnavailableError(Exception):
-    """Raised when Spoolman is unreachable or returns a server/network error."""
-
-
-class SpoolmanClientError(Exception):
-    """Raised when Spoolman returns a 4xx client error (not 404).
-
-    Indicates the request was malformed or rejected by Spoolman, not a connectivity failure.
-    """
-
-    def __init__(self, message: str, status_code: int):
-        super().__init__(message)
-        self.status_code = status_code
-
-
 class SpoolmanClient:
 class SpoolmanClient:
     """Client for interacting with Spoolman API."""
     """Client for interacting with Spoolman API."""
 
 
@@ -89,8 +69,6 @@ class SpoolmanClient:
         self.api_url = f"{self.base_url}/api/v1"
         self.api_url = f"{self.base_url}/api/v1"
         self._client: httpx.AsyncClient | None = None
         self._client: httpx.AsyncClient | None = None
         self._connected = False
         self._connected = False
-        # Per-spool locks for atomic read-modify-write in merge_spool_extra.
-        self._extra_locks: dict[int, asyncio.Lock] = {}
 
 
     async def _get_client(self) -> httpx.AsyncClient:
     async def _get_client(self) -> httpx.AsyncClient:
         """Get or create the HTTP client with connection pooling limits.
         """Get or create the HTTP client with connection pooling limits.
@@ -102,9 +80,7 @@ class SpoolmanClient:
         """
         """
         if self._client is None:
         if self._client is None:
             self._client = httpx.AsyncClient(
             self._client = httpx.AsyncClient(
-                timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
-                follow_redirects=False,
-                verify=True,
+                timeout=10.0,
                 limits=httpx.Limits(
                 limits=httpx.Limits(
                     max_keepalive_connections=5,
                     max_keepalive_connections=5,
                     max_connections=10,
                     max_connections=10,
@@ -193,16 +169,13 @@ class SpoolmanClient:
                     await asyncio.sleep(retry_delay)
                     await asyncio.sleep(retry_delay)
                 else:
                 else:
                     logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
                     logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
-                    raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
+                    raise
 
 
     async def get_filaments(self) -> list[dict]:
     async def get_filaments(self) -> list[dict]:
         """Get all internal filaments from Spoolman.
         """Get all internal filaments from Spoolman.
 
 
         Returns:
         Returns:
             List of filament dictionaries.
             List of filament dictionaries.
-
-        Raises:
-            SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
         """
         """
         try:
         try:
             client = await self._get_client()
             client = await self._get_client()
@@ -211,16 +184,13 @@ class SpoolmanClient:
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
             logger.error("Failed to get filaments from Spoolman: %s", e)
             logger.error("Failed to get filaments from Spoolman: %s", e)
-            raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
+            return []
 
 
     async def get_external_filaments(self) -> list[dict]:
     async def get_external_filaments(self) -> list[dict]:
         """Get external/library filaments from Spoolman.
         """Get external/library filaments from Spoolman.
 
 
         Returns:
         Returns:
             List of external filament dictionaries.
             List of external filament dictionaries.
-
-        Raises:
-            SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
         """
         """
         try:
         try:
             client = await self._get_client()
             client = await self._get_client()
@@ -229,16 +199,13 @@ class SpoolmanClient:
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
             logger.error("Failed to get external filaments from Spoolman: %s", e)
             logger.error("Failed to get external filaments from Spoolman: %s", e)
-            raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
+            return []
 
 
     async def get_vendors(self) -> list[dict]:
     async def get_vendors(self) -> list[dict]:
         """Get all vendors from Spoolman.
         """Get all vendors from Spoolman.
 
 
         Returns:
         Returns:
             List of vendor dictionaries.
             List of vendor dictionaries.
-
-        Raises:
-            SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
         """
         """
         try:
         try:
             client = await self._get_client()
             client = await self._get_client()
@@ -247,7 +214,7 @@ class SpoolmanClient:
             return response.json()
             return response.json()
         except Exception as e:
         except Exception as e:
             logger.error("Failed to get vendors from Spoolman: %s", e)
             logger.error("Failed to get vendors from Spoolman: %s", e)
-            raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
+            return []
 
 
     async def create_vendor(self, name: str) -> dict | None:
     async def create_vendor(self, name: str) -> dict | None:
         """Create a new vendor in Spoolman.
         """Create a new vendor in Spoolman.
@@ -307,7 +274,6 @@ class SpoolmanClient:
         vendor_id: int | None = None,
         vendor_id: int | None = None,
         material: str | None = None,
         material: str | None = None,
         color_hex: str | None = None,
         color_hex: str | None = None,
-        color_name: str | None = None,
         weight: float | None = None,
         weight: float | None = None,
         diameter: float = 1.75,
         diameter: float = 1.75,
         density: float | None = None,
         density: float | None = None,
@@ -319,7 +285,6 @@ class SpoolmanClient:
             vendor_id: Vendor ID
             vendor_id: Vendor ID
             material: Material type (PLA, PETG, etc.)
             material: Material type (PLA, PETG, etc.)
             color_hex: Color in hex format (without #)
             color_hex: Color in hex format (without #)
-            color_name: Human-readable colour name (e.g. "Bambu Green")
             weight: Net weight in grams
             weight: Net weight in grams
             diameter: Filament diameter in mm (default 1.75)
             diameter: Filament diameter in mm (default 1.75)
             density: Filament density in g/cm³ (auto-calculated if not provided)
             density: Filament density in g/cm³ (auto-calculated if not provided)
@@ -350,8 +315,6 @@ class SpoolmanClient:
                 # Strip alpha channel if present (RRGGBBAA -> RRGGBB)
                 # Strip alpha channel if present (RRGGBBAA -> RRGGBB)
                 color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
                 color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
                 data["color_hex"] = color_hex
                 data["color_hex"] = color_hex
-            if color_name:
-                data["color_name"] = color_name
             if weight:
             if weight:
                 data["weight"] = weight
                 data["weight"] = weight
 
 
@@ -458,278 +421,6 @@ class SpoolmanClient:
             logger.error("Failed to update spool in Spoolman: %s", e)
             logger.error("Failed to update spool in Spoolman: %s", e)
             return None
             return None
 
 
-    async def _request_spool(
-        self,
-        method: Literal["GET", "PATCH", "DELETE"],
-        spool_id: int,
-        *,
-        json_body: dict | None = None,
-        operation: str,
-    ) -> httpx.Response:
-        """Perform a spool-scoped HTTP request, translating 404 and errors to named exceptions."""
-        try:
-            client = await self._get_client()
-            response = await client.request(
-                method,
-                f"{self.api_url}/spool/{spool_id}",
-                json=json_body,
-            )
-            if response.status_code == 404:
-                raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
-            response.raise_for_status()
-            return response
-        except SpoolmanNotFoundError:
-            raise
-        except httpx.HTTPStatusError as e:
-            if 400 <= e.response.status_code < 500:
-                logger.warning(
-                    "Spoolman returned %d for %s spool %s",
-                    e.response.status_code,
-                    operation,
-                    spool_id,
-                )
-                raise SpoolmanClientError(
-                    f"Spoolman rejected {operation} for spool {spool_id} (HTTP {e.response.status_code})",
-                    e.response.status_code,
-                ) from e
-            else:
-                logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
-                raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
-        except Exception as e:
-            logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
-            raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
-
-    async def get_spool(self, spool_id: int) -> dict:
-        """Get a single spool by ID from Spoolman.
-
-        Args:
-            spool_id: Spoolman spool ID
-
-        Returns:
-            Spool dictionary.
-
-        Raises:
-            SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
-            SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
-        """
-        response = await self._request_spool("GET", spool_id, operation="get")
-        return response.json()
-
-    async def get_all_spools(self, allow_archived: bool = False) -> list[dict]:
-        """Get all spools from Spoolman, optionally including archived ones.
-
-        Args:
-            allow_archived: If True, include archived spools in the result.
-
-        Returns:
-            List of spool dictionaries.
-
-        Raises:
-            SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
-        """
-        try:
-            client = await self._get_client()
-            params: dict = {}
-            if allow_archived:
-                params["allow_archived"] = "true"
-            response = await client.get(f"{self.api_url}/spool", params=params or None)
-            response.raise_for_status()
-            return response.json()
-        except Exception as e:
-            logger.error("Failed to get all spools from Spoolman: %s", e)
-            raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
-
-    async def delete_spool(self, spool_id: int) -> None:
-        """Delete a spool from Spoolman.
-
-        Args:
-            spool_id: Spoolman spool ID
-
-        Raises:
-            SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
-            SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
-        """
-        await self._request_spool("DELETE", spool_id, operation="delete")
-
-    async def set_spool_archived(self, spool_id: int, archived: bool) -> dict:
-        """Archive or restore a spool in Spoolman.
-
-        Args:
-            spool_id: Spoolman spool ID
-            archived: True to archive, False to restore.
-
-        Returns:
-            Updated spool dictionary.
-
-        Raises:
-            SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
-            SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
-        """
-        response = await self._request_spool(
-            "PATCH",
-            spool_id,
-            json_body={"archived": archived},
-            operation="archive/restore",
-        )
-        return response.json()
-
-    async def update_spool_full(
-        self,
-        spool_id: int,
-        *,
-        filament_id: int | None = None,
-        remaining_weight: float | None = None,
-        comment: str | None = None,
-        price: float | None = None,
-        location: str | None = None,
-        clear_location: bool = False,
-        extra: dict | None = None,
-    ) -> dict:
-        """Update a spool in Spoolman with comprehensive field support.
-
-        Unlike update_spool, this method does not auto-set last_used and
-        supports updating filament_id, comment, and price.
-
-        Args:
-            spool_id: Spoolman spool ID
-            filament_id: New filament type ID
-            remaining_weight: New remaining weight in grams
-            comment: New comment/note (pass empty string to clear)
-            price: Cost per unit (maps to cost_per_kg usage)
-            location: New location string
-            clear_location: If True, sets location to None
-            extra: Extra fields dict to replace (overwrites the entire extra dict;
-                use merge_spool_extra to preserve other fields)
-
-        Returns:
-            Updated spool dictionary.
-
-        Raises:
-            SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
-            SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
-        """
-        data: dict = {}
-        if filament_id is not None:
-            data["filament_id"] = filament_id
-        if remaining_weight is not None:
-            data["remaining_weight"] = remaining_weight
-        if comment is not None:
-            data["comment"] = comment if comment else None
-        if price is not None:
-            data["price"] = price
-        if clear_location:
-            data["location"] = None
-        elif location is not None:
-            data["location"] = location
-        if extra is not None:
-            data["extra"] = extra
-
-        response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
-        return response.json()
-
-    def extra_lock(self, spool_id: int) -> asyncio.Lock:
-        """Return (creating if needed) the per-spool asyncio.Lock used by merge_spool_extra."""
-        return self._extra_locks.setdefault(spool_id, asyncio.Lock())
-
-    async def merge_spool_extra(self, spool_id: int, new_fields: dict) -> dict:
-        """Fetch current extra dict, merge new_fields in, then PATCH back to Spoolman.
-
-        Safe merge — never blindly overwrites other custom Spoolman extra fields.
-        The operation is serialised per spool_id with an asyncio.Lock to prevent
-        concurrent calls from clobbering each other's writes.
-
-        Args:
-            spool_id: Spoolman spool ID
-            new_fields: Fields to add/update in the extra dict
-
-        Returns:
-            Updated spool dictionary.
-
-        Raises:
-            SpoolmanNotFoundError: If the spool does not exist.
-            SpoolmanUnavailableError: If Spoolman is unreachable.
-        """
-        async with self.extra_lock(spool_id):
-            current = await self.get_spool(spool_id)  # raises on error
-            current_extra: dict = current.get("extra") or {}
-            merged = {**current_extra, **new_fields}
-            return await self.update_spool_full(spool_id=spool_id, extra=merged)
-
-    async def find_or_create_vendor(self, name: str) -> int | None:
-        """Find an existing vendor by name or create a new one.
-
-        Args:
-            name: Vendor name (case-insensitive match)
-
-        Returns:
-            Vendor ID or None on failure.
-        """
-        vendors = await self.get_vendors()
-        name_lower = name.strip().lower()
-        for vendor in vendors:
-            if vendor.get("name", "").strip().lower() == name_lower:
-                return vendor["id"]
-        created = await self.create_vendor(name.strip())
-        return created["id"] if created else None
-
-    async def find_or_create_filament(
-        self,
-        material: str,
-        subtype: str,
-        brand: str | None,
-        color_hex: str,
-        label_weight: int,
-        color_name: str | None = None,
-    ) -> int | None:
-        """Find a matching filament in Spoolman or create a new one.
-
-        Matching uses material + full name + vendor + color_hex (normalised).
-        A new filament is created only when no exact match is found.
-
-        Args:
-            material: Filament material (e.g. "PLA")
-            subtype: Filament subtype (e.g. "Basic"); combined with material as name
-            brand: Vendor/brand name; None skips vendor matching
-            color_hex: 6-char hex colour string (RRGGBB, no #)
-            label_weight: Net spool weight in grams
-            color_name: Human-readable colour name passed to create_filament when creating
-
-        Returns:
-            Filament ID or None on failure.
-        """
-        name = f"{material} {subtype}".strip() if subtype else material
-        color = color_hex[:6].upper() if len(color_hex) >= 6 else color_hex.upper()
-
-        vendor_id: int | None = None
-        if brand:
-            vendor_id = await self.find_or_create_vendor(brand)
-
-        filaments = await self.get_filaments()
-        for f in filaments:
-            f_material = (f.get("material") or "").upper()
-            f_name = (f.get("name") or "").strip()
-            f_color = (f.get("color_hex") or "").upper()[:6]
-            f_vendor = f.get("vendor") or {}
-            f_vendor_name = (f_vendor.get("name") or "").strip().lower()
-
-            material_match = f_material == material.upper()
-            name_match = f_name.lower() == name.lower()
-            color_match = f_color == color
-            vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower()
-
-            if material_match and name_match and color_match and vendor_match:
-                return f["id"]
-
-        filament = await self.create_filament(
-            name=name,
-            vendor_id=vendor_id,
-            material=material,
-            color_hex=color,
-            color_name=color_name,
-            weight=float(label_weight),
-        )
-        return filament["id"] if filament else None
-
     async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
     async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
         """Record filament usage for a spool.
         """Record filament usage for a spool.
 
 
@@ -1281,14 +972,7 @@ async def init_spoolman_client(url: str) -> SpoolmanClient:
 
 
     Returns:
     Returns:
         Initialized SpoolmanClient instance.
         Initialized SpoolmanClient instance.
-
-    Raises:
-        ValueError: If *url* is rejected by the SSRF guard.
     """
     """
-    from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
-
-    assert_safe_spoolman_url(url)
-
     global _spoolman_client
     global _spoolman_client
     if _spoolman_client:
     if _spoolman_client:
         await _spoolman_client.close()
         await _spoolman_client.close()

+ 2 - 10
backend/app/services/spoolman_tracking.py

@@ -71,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 "")
     tray_uuid = str(tray_info.get("tray_uuid", "") or "")
     tag_uid = str(tray_info.get("tag_uid", "") 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):
     if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid):
         return tray_uuid
         return tray_uuid
     if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
     if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
@@ -313,16 +312,9 @@ async def _get_spoolman_client_with_fallback():
 
 
             spoolman_url = await get_setting(db, "spoolman_url")
             spoolman_url = await get_setting(db, "spoolman_url")
             if 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 None
 
 
     return client
     return client

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

@@ -1,160 +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 = {
-            Permission.SETTINGS_READ,
-            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,
-        }
-        incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS
-        assert not incorrectly_denied, (
-            f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"
-        )

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

@@ -7,11 +7,9 @@ import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 from sqlalchemy.ext.asyncio import AsyncSession
 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.api.routes import spoolbuddy as spoolbuddy_routes
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
-from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
 
 
 API = "/api/v1/spoolbuddy"
 API = "/api/v1/spoolbuddy"
 
 
@@ -529,7 +527,7 @@ class TestWriteTagEndpoints:
                 json={
                 json={
                     "device_id": device.device_id,
                     "device_id": device.device_id,
                     "spool_id": spool.id,
                     "spool_id": spool.id,
-                    "tag_uid": "04AABBCC",
+                    "tag_uid": "04AABB",
                     "success": False,
                     "success": False,
                     "message": "Write or verification failed",
                     "message": "Write or verification failed",
                 },
                 },
@@ -561,7 +559,7 @@ class TestWriteTagEndpoints:
                 json={
                 json={
                     "device_id": device.device_id,
                     "device_id": device.device_id,
                     "spool_id": spool.id,
                     "spool_id": spool.id,
-                    "tag_uid": "AABBCCDD",
+                    "tag_uid": "AABB",
                     "success": True,
                     "success": True,
                 },
                 },
             )
             )
@@ -1030,46 +1028,6 @@ class TestUpdateEndpoints:
         )
         )
         assert resp.status_code == 404
         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("SECRET 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 "SECRET" not in body
-        assert "/data/keys" not in body
-        assert "id_ed25519" not in body
-
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
     async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory):
@@ -1257,1058 +1215,3 @@ class TestSystemCommandEndpoints:
         assert resp.status_code == 200
         assert resp.status_code == 200
         data = resp.json()
         data = resp.json()
         assert data["pending_command"] == "restart_browser"
         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) -> dict:
-    """Build a minimal Spoolman spool dict with realistic core weight from filament.spool_weight."""
-    return {
-        "id": spool_id,
-        "filament": {"weight": filament_weight, "spool_weight": spool_weight},
-        "used_weight": 0.0,
-    }
-
-
-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_502_on_client_failure(self, async_client: AsyncClient, spoolman_settings):
-        """502 is returned when Spoolman client update fails (returns None)."""
-        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(return_value=None)
-
-        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 == 502
-
-    @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
-
-
-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 - 1524
backend/tests/integration/test_spoolman_inventory_api.py

@@ -1,1524 +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 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 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/",
-            "http://169.254.169.254/latest/meta-data/",  # AWS IMDS (link-local)
-            "http://[::1]:7912/",  # IPv6 loopback
-            "http://0.0.0.0/",  # unspecified
-            "javascript:alert(1)",
-            "http://224.0.0.1/",  # IPv4 multicast
-            "http://[ff02::1]/",  # IPv6 multicast
-            "http://127.1.2.3/",  # 127.x.x.x loopback range
-            "http://[::ffff:169.254.169.254]/",  # IPv4-mapped IPv6 IMDS bypass
-        ],
-    )
-    async def test_ssrf_blocked_schemes_and_addresses(
-        self,
-        async_client: AsyncClient,
-        db_session,
-        mock_spoolman_client,
-        evil_url: str,
-    ):
-        """SSRF: any Spoolman URL that is not http(s) must be rejected with 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()
-
-        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
-    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
-
-
-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",
-        [
-            ("A" * 30, 200),  # exactly at NFC UID cap — valid
-            ("DEADBEEF12345678", 200),  # 16-char backward compat — valid
-            ("A" * 31, 422),  # one over limit — rejected by Pydantic max_length=30
-            ("A" * 32, 422),  # tray_uuid-length value rejected in tag_uid field
-        ],
-    )
-    async def test_tag_uid_length_boundary(
-        self,
-        async_client: AsyncClient,
-        spoolman_settings,
-        mock_spoolman_client,
-        tag_uid: str,
-        expected_status: int,
-    ):
-        """tag_uid boundary — 30 chars valid (NFC UID max), 31+ rejected."""
-        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."""
-        results = [SAMPLE_SPOOLMAN_SPOOL, None, 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='' must preserve unrelated keys in Spoolman extra dict."""
-        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 "tag" not in sent_extra, "tag key must be removed when tag_uid is cleared"
-        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
-
-        _, kwargs = mock_spoolman_client.update_spool_full.call_args
-        sent_extra = kwargs.get("extra")
-        assert sent_extra is not None
-        assert "tag" not in sent_extra
-        # 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://[::1]:7912/",  # IPv6 loopback
-            "http://0.0.0.0/",  # unspecified
-            "http://10.0.0.1/",  # RFC-1918 private
-            "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://10.0.0.1/",  # RFC-1918 private
-            "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
-            "http://10.0.0.1/",  # RFC-1918 private
-        ],
-    )
-    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 succeeds even when price update fails (Gap 6)."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_bulk_create_succeeds_when_price_update_fails(
-        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
-    ):
-        """Bulk create returns 200 even if update_spool_full (price) raises SpoolmanUnavailableError."""
-        from backend.app.services.spoolman import SpoolmanUnavailableError
-
-        mock_spoolman_client.update_spool_full = AsyncMock(side_effect=SpoolmanUnavailableError("price server down"))
-        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)
-        # Price update failure must not abort the bulk create
-        assert resp.status_code in (200, 207)
-        # Both spools must have been created
-        assert mock_spoolman_client.create_spool.call_count == 2
-        # Price update was attempted for each
-        assert mock_spoolman_client.update_spool_full.call_count == 2

+ 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.stderr = ""
     mock_result.exit_status = 0
     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 = AsyncMock()
     mock_conn.run = AsyncMock(return_value=mock_result)
     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.__aenter__ = AsyncMock(return_value=mock_conn)
     mock_conn.__aexit__ = AsyncMock(return_value=False)
     mock_conn.__aexit__ = AsyncMock(return_value=False)
 
 
     with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn) as mock_connect:
     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 rc == 0
     assert stdout == "hello\n"
     assert stdout == "hello\n"
     assert stderr == ""
     assert stderr == ""
-    # TOFU mode (no known_hosts): returns observed key
-    assert observed_key == "ssh-ed25519 AAAA test"
     kwargs = mock_connect.call_args.kwargs
     kwargs = mock_connect.call_args.kwargs
     assert kwargs["host"] == "10.0.0.1"
     assert kwargs["host"] == "10.0.0.1"
     assert kwargs["username"] == "spoolbuddy"
     assert kwargs["username"] == "spoolbuddy"
     assert kwargs["client_keys"] == [str(key_file)]
     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
     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"] == []
     assert kwargs["config"] == []
     mock_conn.run.assert_awaited_once()
     mock_conn.run.assert_awaited_once()
     run_args = mock_conn.run.call_args
     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
     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
 @pytest.mark.asyncio
 async def test_run_ssh_command_no_subprocess(tmp_path):
 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 = tmp_path / "key"
     key_file.write_text("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 = AsyncMock()
     mock_conn.run = AsyncMock(return_value=mock_result)
     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.__aenter__ = AsyncMock(return_value=mock_conn)
     mock_conn.__aexit__ = AsyncMock(return_value=False)
     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",
         "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
         side_effect=asyncssh.Error(code=0, reason="Connection refused"),
         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 rc == 255
     assert stdout == ""
     assert stdout == ""
@@ -357,7 +308,7 @@ async def test_run_ssh_command_os_error(tmp_path):
         "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
         "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
         side_effect=OSError("Network is unreachable"),
         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 rc == 255
     assert "Network is unreachable" in stderr
     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 = tmp_path / "key"
     key_file.write_text("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()
     mock_conn = AsyncMock()
 
 
     async def hang_enter():
     async def hang_enter():
@@ -378,7 +332,7 @@ async def test_run_ssh_command_timeout(tmp_path):
     mock_conn.__aexit__ = AsyncMock(return_value=False)
     mock_conn.__aexit__ = AsyncMock(return_value=False)
 
 
     with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn):
     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 rc == -1
     assert "timed out" in stderr
     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_status = None
     mock_db_device.update_message = None
     mock_db_device.update_message = None
     mock_db_device.pending_command = None
     mock_db_device.pending_command = None
-    mock_db_device.ssh_host_key = None  # TOFU: no stored key
 
 
     mock_result = MagicMock()
     mock_result = MagicMock()
     mock_result.scalar_one_or_none.return_value = mock_db_device
     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 = []
     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)
         ssh_calls.append(cmd)
-        return 0, "ok", "", "ssh-ed25519 AAAA fakehostkey"
+        return 0, "ok", ""
 
 
     _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
     _, 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.settings") as mock_settings,
         patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
         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.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.core.database.async_session", return_value=mock_ctx),
         patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
         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
     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
 @pytest.mark.asyncio
 async def test_perform_ssh_update_ssh_failure(tmp_path):
 async def test_perform_ssh_update_ssh_failure(tmp_path):
     """SSH connectivity check fails — should set error status."""
     """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").write_text("PRIVATE")
     (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
     (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:
         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)
     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 = []
     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)
         ssh_calls.append(cmd)
         if "fetch" in 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)
     _, 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.settings") as mock_settings,
         patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
         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.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.core.database.async_session", return_value=mock_ctx),
         patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
         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
     # Should stop after git fetch — no checkout, pip, restart
     assert len(ssh_calls) == 2  # echo ok + git fetch
     assert len(ssh_calls) == 2  # echo ok + git fetch
     assert not any("checkout" in c for c in ssh_calls)
     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}"

+ 1 - 48
backend/tests/unit/services/test_spoolman_service.py

@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, Mock, patch
 
 
 import pytest
 import pytest
 
 
-from backend.app.services.spoolman import AMSTray, SpoolmanClient, init_spoolman_client
+from backend.app.services.spoolman import AMSTray, SpoolmanClient
 
 
 
 
 class TestIsBambuLabSpool:
 class TestIsBambuLabSpool:
@@ -444,50 +444,3 @@ class TestSpoolmanClient:
             mock_close.assert_not_called()
             mock_close.assert_not_called()
             # Should sleep once (after first failed attempt)
             # Should sleep once (after first failed attempt)
             assert mock_sleep.call_count == 1
             assert mock_sleep.call_count == 1
-
-
-# ---------------------------------------------------------------------------
-# init_spoolman_client — SSRF guard (B4 / T3)
-# ---------------------------------------------------------------------------
-
-
-class TestInitSpoolmanClientSSRFGuard:
-    """init_spoolman_client must reject unsafe URLs before creating a client."""
-
-    @pytest.mark.asyncio
-    async def test_private_ip_raises_value_error(self):
-        with pytest.raises(ValueError, match="private|loopback|link-local|multicast|unspecified"):
-            await init_spoolman_client("http://10.0.0.1/")
-
-    @pytest.mark.asyncio
-    async def test_link_local_raises_value_error(self):
-        with pytest.raises(ValueError):
-            await init_spoolman_client("http://169.254.169.254/latest/meta-data/")
-
-    @pytest.mark.asyncio
-    async def test_loopback_ip_raises_value_error(self):
-        with pytest.raises(ValueError):
-            await init_spoolman_client("http://127.0.0.1:7912/")
-
-    @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:
 class TestResolveSpoolTag:
     """Tests for _resolve_spool_tag()."""
     """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"}
         tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": "DEADBEEF"}
         assert _resolve_spool_tag(tray) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
         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"}
         tray = {"tray_uuid": "", "tag_uid": "DEADBEEF"}
         assert _resolve_spool_tag(tray) == "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"}
         tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"}
         assert _resolve_spool_tag(tray) == "DEADBEEF"
         assert _resolve_spool_tag(tray) == "DEADBEEF"
 
 
@@ -45,10 +45,6 @@ class TestResolveSpoolTag:
         # global_tray_id 5 -> ams_id 1, tray_id 1
         # global_tray_id 5 -> ams_id 1, tray_id 1
         assert _resolve_spool_tag(tray, "01P00A000000000", 5) == "ABA7845700010001"
         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):
     def test_empty_both(self):
         tray = {"tray_uuid": "", "tag_uid": ""}
         tray = {"tray_uuid": "", "tag_uid": ""}
         assert _resolve_spool_tag(tray) == ""
         assert _resolve_spool_tag(tray) == ""

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

@@ -7,9 +7,7 @@ from backend.app.services.opentag3d import (
     OPENTAG3D_MIME_TYPE,
     OPENTAG3D_MIME_TYPE,
     PAYLOAD_SIZE,
     PAYLOAD_SIZE,
     _build_payload,
     _build_payload,
-    _build_payload_from_dict,
     encode_opentag3d,
     encode_opentag3d,
-    encode_opentag3d_from_mapped,
 )
 )
 
 
 
 
@@ -142,180 +140,3 @@ class TestEncodeOpentag3d:
         data = encode_opentag3d(_make_spool())
         data = encode_opentag3d(_make_spool())
         ntag213_capacity = 36 * 4  # 144 bytes
         ntag213_capacity = 36 * 4  # 144 bytes
         assert len(data) <= ntag213_capacity
         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 - 197
backend/tests/unit/test_spoolbuddy_schema_validation.py

@@ -1,197 +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 bounds
-# ---------------------------------------------------------------------------
-
-
-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_max_weight_accepted(self):
-        req = ScaleReadingRequest(device_id="sb1", weight_grams=100_000.0)
-        assert req.weight_grams == 100_000.0
-
-    def test_negative_weight_rejected(self):
-        with pytest.raises(ValidationError):
-            ScaleReadingRequest(device_id="sb1", weight_grams=-1.0)
-
-    def test_over_max_weight_rejected(self):
-        with pytest.raises(ValidationError):
-            ScaleReadingRequest(device_id="sb1", weight_grams=100_001.0)

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

@@ -1,246 +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

+ 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_returns_none_when_create_fails(self, client):
-        with (
-            patch.object(client, "get_vendors", AsyncMock(return_value=[])),
-            patch.object(client, "create_vendor", AsyncMock(return_value=None)),
-        ):
-            result = await client.find_or_create_vendor("Ghost Brand")
-        assert result is None
-
-
-# ---------------------------------------------------------------------------
-# 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_returns_none_when_create_fails(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=None)),
-        ):
-            result = await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500)
-        assert result is None
-
-
-# ---------------------------------------------------------------------------
-# 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 - 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 - 107
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -28,11 +28,6 @@ vi.mock('../../api/client', () => ({
     createSpool: vi.fn().mockResolvedValue({ id: 99 }),
     createSpool: vi.fn().mockResolvedValue({ id: 99 }),
     updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
     updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
-    bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
-      created: [{ id: 1, material: 'PLA' }],
-      requested_count: 1,
-      failed_count: 0,
-    }),
   },
   },
 }));
 }));
 
 
@@ -420,108 +415,6 @@ describe('SpoolFormModal weightTouched', () => {
     expect((payload as { rgba: string }).rgba).toBe('FF0000FF');
     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 () => {
   it('displays correct catalog name when duplicates exist', async () => {
     const spoolWithCatalogId: InventorySpool = {
     const spoolWithCatalogId: InventorySpool = {
       ...existingSpool,
       ...existingSpool,

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

@@ -1,233 +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 });
-    });
-  });
-});

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

@@ -1,175 +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';
-
-// Replicate the search filter from InventoryPage
-function applySearch(spools: InventorySpool[], search: string): InventorySpool[] {
-  if (!search) return spools;
-  const q = search.toLowerCase();
-  return spools.filter((s) =>
-    s.brand?.toLowerCase().includes(q) ||
-    s.material.toLowerCase().includes(q) ||
-    s.color_name?.toLowerCase().includes(q) ||
-    s.subtype?.toLowerCase().includes(q) ||
-    s.note?.toLowerCase().includes(q) ||
-    s.slicer_filament_name?.toLowerCase().includes(q) ||
-    s.storage_location?.toLowerCase().includes(q)
-  );
-}
-
-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]);
-    });
-  });
-});

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

@@ -13,9 +13,7 @@ import React from 'react';
 import { render } from '@testing-library/react';
 import { render } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
 import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
-import { ToastProvider } from '../../contexts/ToastContext';
 import { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage';
 import { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage';
-import { api as mockedApi, spoolbuddyApi as mockedSpoolbuddyApi } from '../../api/client';
 
 
 // Mock the API modules
 // Mock the API modules
 vi.mock('../../api/client', () => ({
 vi.mock('../../api/client', () => ({
@@ -70,15 +68,13 @@ function renderPage() {
 
 
   return render(
   return render(
     <QueryClientProvider client={queryClient}>
     <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>
     </QueryClientProvider>
   );
   );
 }
 }
@@ -138,49 +134,4 @@ describe('SpoolBuddyWriteTagPage', () => {
     expect(screen.getByText('Select a spool, then place a blank NTAG on the reader')).toBeDefined();
     expect(screen.getByText('Select a spool, then place a blank NTAG on the reader')).toBeDefined();
     mockOutletContext.sbState.deviceOnline = false; // reset
     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 - 81
frontend/src/__tests__/utils/spoolFormValidation.test.ts

@@ -1,81 +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);
-    });
-  });
-});

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

@@ -2,15 +2,6 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/
 
 
 const API_BASE = '/api/v1';
 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
 // Auth token storage
 // By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
 // 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
 // When the token originates from the ?token= URL param (kiosk bootstrap), it is
@@ -109,7 +100,7 @@ async function request<T>(
       }
       }
     }
     }
 
 
-    throw new ApiError(message, response.status);
+    throw new Error(message);
   }
   }
 
 
   // Handle empty responses (204 No Content, etc.)
   // Handle empty responses (204 No Content, etc.)
@@ -2121,13 +2112,6 @@ export interface InventorySpool {
   last_scale_weight: number | null;
   last_scale_weight: number | null;
   last_weighed_at: string | null;
   last_weighed_at: string | null;
   k_profiles?: SpoolKProfile[];
   k_profiles?: SpoolKProfile[];
-  storage_location?: string | null;
-}
-
-export interface SpoolmanBulkCreateResult {
-  created: InventorySpool[];
-  requested_count: number;
-  failed_count: number;
 }
 }
 
 
 export interface SpoolUsageRecord {
 export interface SpoolUsageRecord {
@@ -4233,44 +4217,6 @@ export const api = {
   getFilamentPresets: () =>
   getFilamentPresets: () =>
     request<SlicerSetting[]>('/cloud/filaments'),
     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' }),
-  syncSpoolmanSpoolWeight: (spoolId: number, weightGrams: number) =>
-    request<{ status: string; weight_used: number }>(`/spoolman/inventory/spools/${spoolId}/weight`, {
-      method: 'PATCH',
-      body: JSON.stringify({ weight_grams: weightGrams }),
-    }),
-
   // Updates
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),
   getVersion: () => request<VersionInfo>('/updates/version'),
   checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
   checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
@@ -5799,7 +5745,7 @@ export const spoolbuddyApi = {
     request<{ public_key: string }>('/spoolbuddy/ssh/public-key'),
     request<{ public_key: string }>('/spoolbuddy/ssh/public-key'),
 
 
   writeTag: (deviceId: string, spoolId: number) =>
   writeTag: (deviceId: string, spoolId: number) =>
-    request<{ status: string; warnings?: string[] }>('/spoolbuddy/nfc/write-tag', {
+    request<{ status: string }>('/spoolbuddy/nfc/write-tag', {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }),
       body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }),
     }),
     }),

+ 12 - 25
frontend/src/components/FilamentHoverCard.tsx

@@ -1,7 +1,6 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
 import { useState, useRef, useEffect, type ReactNode } from 'react';
-import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Droplets, Link2, 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';
 import { isLightColor } from '../utils/colors';
 
 
 interface FilamentData {
 interface FilamentData {
@@ -52,7 +51,6 @@ interface FilamentHoverCardProps {
  */
  */
 export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) {
 export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const navigate = useNavigate();
   const [isVisible, setIsVisible] = useState(false);
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [copied, setCopied] = useState(false);
   const [copied, setCopied] = useState(false);
@@ -293,20 +291,20 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     )}
                     )}
                   </div>
                   </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"
                         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') && (
                       {spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && (
                         <button
                         <button
@@ -363,17 +361,6 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                         {inventory.assignedSpool.material}
                         {inventory.assignedSpool.material}
                         {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
                         {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
                       </p>
                       </p>
-                      <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 && (
                       {inventory.onUnassignSpool && (
                         <button
                         <button
                           onClick={(e) => {
                           onClick={(e) => {

+ 38 - 95
frontend/src/components/SpoolFormModal.tsx

@@ -2,8 +2,8 @@ import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
 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 } from '../api/client';
+import { api } from '../api/client';
+import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
@@ -18,8 +18,6 @@ import { SpoolUsageHistory } from './SpoolUsageHistory';
 
 
 type TabId = 'filament' | 'pa-profile';
 type TabId = 'filament' | 'pa-profile';
 
 
-const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
-
 interface SpoolFormModalProps {
 interface SpoolFormModalProps {
   isOpen: boolean;
   isOpen: boolean;
   onClose: () => void;
   onClose: () => void;
@@ -27,10 +25,6 @@ interface SpoolFormModalProps {
   printersWithCalibrations?: PrinterWithCalibrations[];
   printersWithCalibrations?: PrinterWithCalibrations[];
   currencySymbol: string;
   currencySymbol: string;
   onSpoolsCreated?: (spools: InventorySpool[]) => void;
   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({
 export function SpoolFormModal({
@@ -40,8 +34,6 @@ export function SpoolFormModal({
   printersWithCalibrations = [],
   printersWithCalibrations = [],
   currencySymbol,
   currencySymbol,
   onSpoolsCreated,
   onSpoolsCreated,
-  spoolmanMode = false,
-  spoolsQueryKey = ['inventory-spools'],
 }: SpoolFormModalProps) {
 }: SpoolFormModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -86,7 +78,9 @@ export function SpoolFormModal({
     : fetchedCalibrations;
     : fetchedCalibrations;
 
 
   // Count selected PA profiles for tab badge
   // Count selected PA profiles for tab badge
-  const selectedProfileCount = selectedProfiles.size;
+  const selectedProfileCount = useMemo(() => {
+    return selectedProfiles.size;
+  }, [selectedProfiles]);
 
 
   // Load recent colors on mount
   // Load recent colors on mount
   useEffect(() => {
   useEffect(() => {
@@ -282,7 +276,6 @@ export function SpoolFormModal({
           slicer_filament: spool.slicer_filament || '',
           slicer_filament: spool.slicer_filament || '',
           note: spool.note || '',
           note: spool.note || '',
           cost_per_kg: spool.cost_per_kg ?? null,
           cost_per_kg: spool.cost_per_kg ?? null,
-          storage_location: spool.storage_location || '',
         });
         });
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
 
 
@@ -332,119 +325,71 @@ export function SpoolFormModal({
     setRecentColors(prev => saveRecentColor(color, prev));
     setRecentColors(prev => saveRecentColor(color, prev));
   };
   };
 
 
-  // Mutations – dispatch to Spoolman proxy or local inventory based on mode
+  // Mutations
   const createMutation = useMutation({
   const createMutation = useMutation({
     mutationFn: (data: Record<string, unknown>) =>
     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) => {
     onSuccess: async (newSpool) => {
-      if (!spoolmanMode && selectedProfiles.size > 0 && newSpool?.id) {
+      // Save K-profiles if any selected
+      if (selectedProfiles.size > 0 && newSpool?.id) {
         await saveKProfiles(newSpool.id);
         await saveKProfiles(newSpool.id);
       }
       }
-      await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       showToast(t('inventory.spoolCreated'), 'success');
       showToast(t('inventory.spoolCreated'), 'success');
       onClose();
       onClose();
     },
     },
     onError: (error: Error) => {
     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[]);
-
-      if (!spoolmanMode && selectedProfiles.size > 0) {
-        for (const s of createdSpools) {
-          await saveKProfiles(s.id);
+  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 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();
       onClose();
     },
     },
     onError: (error: Error) => {
     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({
   const updateMutation = useMutation({
     mutationFn: (data: Record<string, unknown>) =>
     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 () => {
     onSuccess: async () => {
-      if (!spoolmanMode && spool?.id) {
+      // Save K-profiles
+      if (spool?.id) {
         await saveKProfiles(spool.id);
         await saveKProfiles(spool.id);
       }
       }
-      await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.spoolUpdated'), 'success');
       showToast(t('inventory.spoolUpdated'), 'success');
       onClose();
       onClose();
     },
     },
     onError: (error: Error) => {
     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({
   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 () => {
     onSuccess: async () => {
-      await queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
       showToast(t('inventory.tagDeleted', 'Tag removed'), 'success');
       onClose();
       onClose();
     },
     },
     onError: (error: Error) => {
     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');
     },
     },
   });
   });
 
 
@@ -477,9 +422,8 @@ export function SpoolFormModal({
       // Clear existing K-profiles
       // Clear existing K-profiles
       try {
       try {
         await api.saveSpoolKProfiles(spoolId, []);
         await api.saveSpoolKProfiles(spoolId, []);
-      } catch (e) {
-        console.error('Failed to save K-profiles:', e);
-        showToast(t('inventory.kProfileSaveFailed'), 'warning');
+      } catch {
+        // Ignore
       }
       }
       return;
       return;
     }
     }
@@ -514,7 +458,6 @@ export function SpoolFormModal({
         await api.saveSpoolKProfiles(spoolId, profiles);
         await api.saveSpoolKProfiles(spoolId, profiles);
       } catch (e) {
       } catch (e) {
         console.error('Failed to save K-profiles:', e);
         console.error('Failed to save K-profiles:', e);
-        showToast(t('inventory.kProfileSaveFailed'), 'warning');
       }
       }
     }
     }
   };
   };
@@ -532,9 +475,10 @@ export function SpoolFormModal({
   if (!isOpen) return null;
   if (!isOpen) return null;
 
 
   const handleSubmit = () => {
   const handleSubmit = () => {
-    const validation = validateForm(formData, quickAdd, spoolmanMode);
+    const validation = validateForm(formData, quickAdd);
     if (!validation.isValid) {
     if (!validation.isValid) {
       setErrors(validation.errors);
       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) {
       if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
         setActiveTab('filament');
         setActiveTab('filament');
       }
       }
@@ -559,7 +503,6 @@ export function SpoolFormModal({
       nozzle_temp_max: null,
       nozzle_temp_max: null,
       note: formData.note || null,
       note: formData.note || null,
       cost_per_kg: formData.cost_per_kg,
       cost_per_kg: formData.cost_per_kg,
-      storage_location: formData.storage_location || null,
     };
     };
 
 
     // Only send weight_used when creating or when explicitly changed by the user.
     // Only send weight_used when creating or when explicitly changed by the user.
@@ -639,7 +582,7 @@ export function SpoolFormModal({
             <Palette className="w-4 h-4" />
             <Palette className="w-4 h-4" />
             {t('inventory.filamentInfoTab')}
             {t('inventory.filamentInfoTab')}
           </button>
           </button>
-          {!quickAdd && !spoolmanMode && (
+          {!quickAdd && (
             <button
             <button
               onClick={() => setActiveTab('pa-profile')}
               onClick={() => setActiveTab('pa-profile')}
               className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
               className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
@@ -713,8 +656,8 @@ export function SpoolFormModal({
                 />
                 />
               </div>
               </div>
 
 
-              {/* Usage History (only when editing internal inventory; Spoolman tracks its own) */}
-              {isEditing && spool && !spoolmanMode && (
+              {/* Usage History (only when editing) */}
+              {isEditing && spool && (
                 <div>
                 <div>
                   <SpoolUsageHistory spoolId={spool.id} />
                   <SpoolUsageHistory spoolId={spool.id} />
                 </div>
                 </div>

+ 0 - 13
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -313,19 +313,6 @@ export function AdditionalSection({
           onChange={(e) => updateField('note', e.target.value)}
           onChange={(e) => updateField('note', e.target.value)}
         />
         />
       </div>
       </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>
     </div>
   );
   );
 }
 }

+ 2 - 9
frontend/src/components/spool-form/types.ts

@@ -23,7 +23,6 @@ export interface SpoolFormData {
   slicer_filament: string;
   slicer_filament: string;
   note: string;
   note: string;
   cost_per_kg: number | null;
   cost_per_kg: number | null;
-  storage_location: string;
 }
 }
 
 
 export const defaultFormData: SpoolFormData = {
 export const defaultFormData: SpoolFormData = {
@@ -39,7 +38,6 @@ export const defaultFormData: SpoolFormData = {
   slicer_filament: '',
   slicer_filament: '',
   note: '',
   note: '',
   cost_per_kg: null,
   cost_per_kg: null,
-  storage_location: '',
 };
 };
 
 
 // Printer with calibrations type
 // Printer with calibrations type
@@ -125,15 +123,10 @@ export interface ValidationResult {
   errors: Partial<Record<keyof SpoolFormData, string>>;
   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>> = {};
   const errors: Partial<Record<keyof SpoolFormData, string>> = {};
 
 
-  // Quick-add and Spoolman mode only require material
-  if (quickAdd || spoolmanMode) {
+  if (quickAdd) {
     if (!formData.material) {
     if (!formData.material) {
       errors.material = 'Material is required';
       errors.material = 'Material is required';
     }
     }

+ 0 - 20
frontend/src/i18n/locales/de.ts

@@ -3288,9 +3288,6 @@ export default {
     measuredWeight: 'Gemessenes Gewicht',
     measuredWeight: 'Gemessenes Gewicht',
     spoolName: 'Spule',
     spoolName: 'Spule',
     costPerKg: 'Kosten pro kg',
     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.',
     measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.',
     slicerFilament: 'Slicer-Filament',
     slicerFilament: 'Slicer-Filament',
     slicerFilamentName: 'Slicer-Preset-Name',
     slicerFilamentName: 'Slicer-Preset-Name',
@@ -3324,27 +3321,11 @@ export default {
     stock: 'Lager',
     stock: 'Lager',
     configured: 'Konfiguriert',
     configured: 'Konfiguriert',
     spoolsCreated: '{{count}} Spulen erstellt',
     spoolsCreated: '{{count}} Spulen erstellt',
-    spoolsPartiallyCreated: '{{created}} von {{total}} Spulen erstellt (einige fehlgeschlagen)',
     spoolCreated: 'Spule erstellt',
     spoolCreated: 'Spule erstellt',
     spoolUpdated: 'Spule aktualisiert',
     spoolUpdated: 'Spule aktualisiert',
     spoolDeleted: 'Spule gelöscht',
     spoolDeleted: 'Spule gelöscht',
-    deepLinkSpoolNotFound: 'Spule nicht gefunden',
-    deepLinkFetchFailed: 'Spule konnte nicht geladen werden — bitte erneut versuchen',
     spoolArchived: 'Spule archiviert',
     spoolArchived: 'Spule archiviert',
     spoolRestored: 'Spule wiederhergestellt',
     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.',
     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?',
     archiveConfirm: 'Möchten Sie diese Spule wirklich archivieren?',
     advancedSettings: 'Erweiterte Einstellungen',
     advancedSettings: 'Erweiterte Einstellungen',
@@ -5030,7 +5011,6 @@ export default {
       creating: 'Wird erstellt...',
       creating: 'Wird erstellt...',
       spoolCreated: 'Spule erstellt! Bereit zum Schreiben.',
       spoolCreated: 'Spule erstellt! Bereit zum Schreiben.',
       createFailed: 'Spule konnte nicht erstellt werden',
       createFailed: 'Spule konnte nicht erstellt werden',
-      incompleteDataWarning: 'Tag mit unvollständigen Spoolman-Daten geschrieben',
     },
     },
     quickMenu: {
     quickMenu: {
       printerPower: 'Drucker-Strom',
       printerPower: 'Drucker-Strom',

+ 0 - 20
frontend/src/i18n/locales/en.ts

@@ -3291,9 +3291,6 @@ export default {
     measuredWeight: 'Measured Weight',
     measuredWeight: 'Measured Weight',
     spoolName: 'Spool',
     spoolName: 'Spool',
     costPerKg: 'Cost per kg',
     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.',
     measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.',
     slicerFilament: 'Slicer Filament',
     slicerFilament: 'Slicer Filament',
     slicerFilamentName: 'Slicer Preset Name',
     slicerFilamentName: 'Slicer Preset Name',
@@ -3327,27 +3324,11 @@ export default {
     stock: 'Stock',
     stock: 'Stock',
     configured: 'Configured',
     configured: 'Configured',
     spoolsCreated: '{{count}} spools created',
     spoolsCreated: '{{count}} spools created',
-    spoolsPartiallyCreated: '{{created}} of {{total}} spools created (some failed)',
     spoolCreated: 'Spool created',
     spoolCreated: 'Spool created',
     spoolUpdated: 'Spool updated',
     spoolUpdated: 'Spool updated',
     spoolDeleted: 'Spool deleted',
     spoolDeleted: 'Spool deleted',
-    deepLinkSpoolNotFound: 'Spool not found',
-    deepLinkFetchFailed: 'Could not load spool — try again',
     spoolArchived: 'Spool archived',
     spoolArchived: 'Spool archived',
     spoolRestored: 'Spool restored',
     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.',
     deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',
     archiveConfirm: 'Are you sure you want to archive this spool?',
     archiveConfirm: 'Are you sure you want to archive this spool?',
     advancedSettings: 'Advanced Settings',
     advancedSettings: 'Advanced Settings',
@@ -5038,7 +5019,6 @@ export default {
       creating: 'Creating...',
       creating: 'Creating...',
       spoolCreated: 'Spool created! Ready to write.',
       spoolCreated: 'Spool created! Ready to write.',
       createFailed: 'Failed to create spool',
       createFailed: 'Failed to create spool',
-      incompleteDataWarning: 'Tag written with incomplete Spoolman data',
     },
     },
     quickMenu: {
     quickMenu: {
       printerPower: 'Printer Power',
       printerPower: 'Printer Power',

+ 0 - 20
frontend/src/i18n/locales/fr.ts

@@ -3210,9 +3210,6 @@ export default {
     measuredWeight: 'Poids mesuré',
     measuredWeight: 'Poids mesuré',
     spoolName: 'Bobine',
     spoolName: 'Bobine',
     costPerKg: 'Coût par kg',
     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.',
     measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.',
     slicerFilament: 'Filament Slicer',
     slicerFilament: 'Filament Slicer',
     slicerFilamentName: 'Nom du Preset Slicer',
     slicerFilamentName: 'Nom du Preset Slicer',
@@ -3246,27 +3243,11 @@ export default {
     stock: 'Stock',
     stock: 'Stock',
     configured: 'Configuré',
     configured: 'Configuré',
     spoolsCreated: '{{count}} bobines créées',
     spoolsCreated: '{{count}} bobines créées',
-    spoolsPartiallyCreated: '{{created}} sur {{total}} bobines créées (certaines ont échoué)',
     spoolCreated: 'Bobine créée',
     spoolCreated: 'Bobine créée',
     spoolUpdated: 'Bobine mise à jour',
     spoolUpdated: 'Bobine mise à jour',
     spoolDeleted: 'Bobine supprimée',
     spoolDeleted: 'Bobine supprimée',
-    deepLinkSpoolNotFound: 'Bobine introuvable',
-    deepLinkFetchFailed: 'Impossible de charger la bobine — réessayez',
     spoolArchived: 'Bobine archivée',
     spoolArchived: 'Bobine archivée',
     spoolRestored: 'Bobine restauré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 ?',
     deleteConfirm: 'Supprimer définitivement cette bobine ?',
     archiveConfirm: 'Voulez-vous vraiment archiver cette bobine ?',
     archiveConfirm: 'Voulez-vous vraiment archiver cette bobine ?',
     advancedSettings: 'Paramètres Avancés',
     advancedSettings: 'Paramètres Avancés',
@@ -4944,7 +4925,6 @@ export default {
       creating: 'Création...',
       creating: 'Création...',
       spoolCreated: 'Bobine créée ! Prêt à écrire.',
       spoolCreated: 'Bobine créée ! Prêt à écrire.',
       createFailed: 'Impossible de créer la bobine',
       createFailed: 'Impossible de créer la bobine',
-      incompleteDataWarning: 'Tag écrit avec des données Spoolman incomplètes',
     },
     },
     quickMenu: {
     quickMenu: {
       printerPower: 'Alimentation imprimante',
       printerPower: 'Alimentation imprimante',

+ 0 - 20
frontend/src/i18n/locales/it.ts

@@ -3209,9 +3209,6 @@ export default {
     measuredWeight: 'Peso Misurato',
     measuredWeight: 'Peso Misurato',
     spoolName: 'Bobina',
     spoolName: 'Bobina',
     costPerKg: 'Costo per kg',
     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.',
     measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.',
     slicerFilament: 'Filamento Slicer',
     slicerFilament: 'Filamento Slicer',
     slicerFilamentName: 'Nome Preset Slicer',
     slicerFilamentName: 'Nome Preset Slicer',
@@ -3245,27 +3242,11 @@ export default {
     stock: 'Scorta',
     stock: 'Scorta',
     configured: 'Configurata',
     configured: 'Configurata',
     spoolsCreated: '{{count}} bobine create',
     spoolsCreated: '{{count}} bobine create',
-    spoolsPartiallyCreated: '{{created}} di {{total}} bobine create (alcune non riuscite)',
     spoolCreated: 'Bobina creata',
     spoolCreated: 'Bobina creata',
     spoolUpdated: 'Bobina aggiornata',
     spoolUpdated: 'Bobina aggiornata',
     spoolDeleted: 'Bobina eliminata',
     spoolDeleted: 'Bobina eliminata',
-    deepLinkSpoolNotFound: 'Bobina non trovata',
-    deepLinkFetchFailed: 'Impossibile caricare la bobina — riprova',
     spoolArchived: 'Bobina archiviata',
     spoolArchived: 'Bobina archiviata',
     spoolRestored: 'Bobina ripristinata',
     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.',
     deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
     archiveConfirm: 'Sei sicuro di voler archiviare questa bobina?',
     archiveConfirm: 'Sei sicuro di voler archiviare questa bobina?',
     advancedSettings: 'Impostazioni Avanzate',
     advancedSettings: 'Impostazioni Avanzate',
@@ -4943,7 +4924,6 @@ export default {
       creating: 'Creazione...',
       creating: 'Creazione...',
       spoolCreated: 'Bobina creata! Pronto per la scrittura.',
       spoolCreated: 'Bobina creata! Pronto per la scrittura.',
       createFailed: 'Impossibile creare la bobina',
       createFailed: 'Impossibile creare la bobina',
-      incompleteDataWarning: 'Tag scritto con dati Spoolman incompleti',
     },
     },
     quickMenu: {
     quickMenu: {
       printerPower: 'Alimentazione stampante',
       printerPower: 'Alimentazione stampante',

+ 0 - 20
frontend/src/i18n/locales/ja.ts

@@ -3248,9 +3248,6 @@ export default {
     measuredWeight: '計測重量',
     measuredWeight: '計測重量',
     spoolName: 'スプール',
     spoolName: 'スプール',
     costPerKg: 'kgあたりのコスト',
     costPerKg: 'kgあたりのコスト',
-    storageLocation: '保管場所',
-    storageLocationPlaceholder: '例:棚A、引き出し1',
-    openInInventory: 'インベントリで開く',
     measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
     measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。',
     slicerFilament: 'スライサーフィラメント',
     slicerFilament: 'スライサーフィラメント',
     slicerFilamentName: 'スライサープリセット名',
     slicerFilamentName: 'スライサープリセット名',
@@ -3284,27 +3281,11 @@ export default {
     stock: '在庫',
     stock: '在庫',
     configured: '設定済み',
     configured: '設定済み',
     spoolsCreated: '{{count}}本のスプールを作成しました',
     spoolsCreated: '{{count}}本のスプールを作成しました',
-    spoolsPartiallyCreated: '{{total}}本中{{created}}本のスプールを作成しました(一部失敗)',
     spoolCreated: 'スプールを作成しました',
     spoolCreated: 'スプールを作成しました',
     spoolUpdated: 'スプールを更新しました',
     spoolUpdated: 'スプールを更新しました',
     spoolDeleted: 'スプールを削除しました',
     spoolDeleted: 'スプールを削除しました',
-    deepLinkSpoolNotFound: 'スプールが見つかりません',
-    deepLinkFetchFailed: 'スプールを読み込めませんでした — もう一度お試しください',
     spoolArchived: 'スプールをアーカイブしました',
     spoolArchived: 'スプールをアーカイブしました',
     spoolRestored: 'スプールを復元しました',
     spoolRestored: 'スプールを復元しました',
-    kProfileSaveFailed: 'Kプロファイル設定を保存できませんでした',
-    syncWeightSpoolNotFound: 'スプールが見つかりません — 削除された可能性があります',
-    syncWeightSpoolmanUnreachable: 'Spoolmanに接続できません — 後でもう一度お試しください',
-    syncWeightFailed: '重量の同期に失敗しました',
-    spoolmanUnreachable: 'Spoolmanに接続できません — 後でもう一度お試しください',
-    deleteSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
-    deleteFailed: 'スプールを削除できませんでした',
-    archiveSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
-    archiveFailed: 'スプールをアーカイブできませんでした',
-    restoreSpoolNotFound: 'スプールが見つかりません — すでに削除された可能性があります',
-    restoreFailed: 'スプールを復元できませんでした',
-    saveFailed: '変更を保存できませんでした',
-    tagClearFailed: 'タグを削除できませんでした',
     deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
     deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
     archiveConfirm: 'このスプールをアーカイブしますか?',
     archiveConfirm: 'このスプールをアーカイブしますか?',
     advancedSettings: '詳細設定',
     advancedSettings: '詳細設定',
@@ -4982,7 +4963,6 @@ export default {
       creating: '作成中...',
       creating: '作成中...',
       spoolCreated: 'スプール作成完了!書込み準備ができました。',
       spoolCreated: 'スプール作成完了!書込み準備ができました。',
       createFailed: 'スプールの作成に失敗しました',
       createFailed: 'スプールの作成に失敗しました',
-      incompleteDataWarning: '不完全なSpoolmanデータでタグを書き込みました',
     },
     },
     quickMenu: {
     quickMenu: {
       printerPower: 'プリンター電源',
       printerPower: 'プリンター電源',

+ 0 - 20
frontend/src/i18n/locales/pt-BR.ts

@@ -3223,9 +3223,6 @@ export default {
     measuredWeight: 'Peso Medido',
     measuredWeight: 'Peso Medido',
     spoolName: 'Bobina',
     spoolName: 'Bobina',
     costPerKg: 'Custo por kg',
     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.',
     measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.',
     slicerFilament: 'Filamento do Fatiador',
     slicerFilament: 'Filamento do Fatiador',
     slicerFilamentName: 'Nome do Predefinido do Fatiador',
     slicerFilamentName: 'Nome do Predefinido do Fatiador',
@@ -3259,27 +3256,11 @@ export default {
     stock: 'Estoque',
     stock: 'Estoque',
     configured: 'Configurado',
     configured: 'Configurado',
     spoolsCreated: '{{count}} carretéis criados',
     spoolsCreated: '{{count}} carretéis criados',
-    spoolsPartiallyCreated: '{{created}} de {{total}} carretéis criados (alguns falharam)',
     spoolCreated: 'Carretel criado',
     spoolCreated: 'Carretel criado',
     spoolUpdated: 'Carretel atualizado',
     spoolUpdated: 'Carretel atualizado',
     spoolDeleted: 'Carretel excluído',
     spoolDeleted: 'Carretel excluído',
-    deepLinkSpoolNotFound: 'Carretel não encontrado',
-    deepLinkFetchFailed: 'Não foi possível carregar o carretel — tente novamente',
     spoolArchived: 'Carretel arquivado',
     spoolArchived: 'Carretel arquivado',
     spoolRestored: 'Carretel restaurado',
     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.',
     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?',
     archiveConfirm: 'Tem certeza de que deseja arquivar este carretel?',
     advancedSettings: 'Configurações Avançadas',
     advancedSettings: 'Configurações Avançadas',
@@ -4957,7 +4938,6 @@ export default {
       creating: 'Criando...',
       creating: 'Criando...',
       spoolCreated: 'Bobina criada! Pronto para gravar.',
       spoolCreated: 'Bobina criada! Pronto para gravar.',
       createFailed: 'Falha ao criar bobina',
       createFailed: 'Falha ao criar bobina',
-      incompleteDataWarning: 'Tag gravada com dados Spoolman incompletos',
     },
     },
     quickMenu: {
     quickMenu: {
       printerPower: 'Energia da impressora',
       printerPower: 'Energia da impressora',

+ 0 - 20
frontend/src/i18n/locales/zh-CN.ts

@@ -3275,9 +3275,6 @@ export default {
     measuredWeight: '称量重量',
     measuredWeight: '称量重量',
     spoolName: '线轴',
     spoolName: '线轴',
     costPerKg: '每公斤成本',
     costPerKg: '每公斤成本',
-    storageLocation: '存放位置',
-    storageLocationPlaceholder: '例如:货架A,抽屉1',
-    openInInventory: '在库存中查看',
     measuredWeightError: '称量重量必须在 {{min}}g 到 {{max}}g 之间。',
     measuredWeightError: '称量重量必须在 {{min}}g 到 {{max}}g 之间。',
     slicerFilament: '切片耗材',
     slicerFilament: '切片耗材',
     slicerFilamentName: '切片预设名称',
     slicerFilamentName: '切片预设名称',
@@ -3316,27 +3313,11 @@ export default {
     stock: '库存',
     stock: '库存',
     configured: '已配置',
     configured: '已配置',
     spoolsCreated: '已创建 {{count}} 个耗材',
     spoolsCreated: '已创建 {{count}} 个耗材',
-    spoolsPartiallyCreated: '已创建 {{created}} / {{total}} 个耗材(部分失败)',
     spoolCreated: '耗材已创建',
     spoolCreated: '耗材已创建',
     spoolUpdated: '耗材已更新',
     spoolUpdated: '耗材已更新',
     spoolDeleted: '耗材已删除',
     spoolDeleted: '耗材已删除',
-    deepLinkSpoolNotFound: '未找到耗材',
-    deepLinkFetchFailed: '无法加载耗材 — 请重试',
     spoolArchived: '耗材已归档',
     spoolArchived: '耗材已归档',
     spoolRestored: '耗材已恢复',
     spoolRestored: '耗材已恢复',
-    kProfileSaveFailed: 'K值配置文件设置无法保存',
-    syncWeightSpoolNotFound: '未找到耗材 — 可能已被删除',
-    syncWeightSpoolmanUnreachable: 'Spoolman 无法访问 — 请稍后再试',
-    syncWeightFailed: '重量同步失败',
-    spoolmanUnreachable: 'Spoolman 无法访问 — 请稍后再试',
-    deleteSpoolNotFound: '未找到耗材 — 可能已被删除',
-    deleteFailed: '删除耗材失败',
-    archiveSpoolNotFound: '未找到耗材 — 可能已被删除',
-    archiveFailed: '归档耗材失败',
-    restoreSpoolNotFound: '未找到耗材 — 可能已被删除',
-    restoreFailed: '恢复耗材失败',
-    saveFailed: '保存更改失败',
-    tagClearFailed: '清除标签失败',
     deleteConfirm: '确定要删除此耗材吗?此操作无法撤销。',
     deleteConfirm: '确定要删除此耗材吗?此操作无法撤销。',
     archiveConfirm: '确定要归档此耗材吗?',
     archiveConfirm: '确定要归档此耗材吗?',
     advancedSettings: '高级设置',
     advancedSettings: '高级设置',
@@ -5021,7 +5002,6 @@ export default {
       creating: '创建中...',
       creating: '创建中...',
       spoolCreated: '耗材已创建!准备写入。',
       spoolCreated: '耗材已创建!准备写入。',
       createFailed: '创建耗材失败',
       createFailed: '创建耗材失败',
-      incompleteDataWarning: '已使用不完整的Spoolman数据写入标签',
     },
     },
     quickMenu: {
     quickMenu: {
       printerPower: '打印机电源',
       printerPower: '打印机电源',

+ 0 - 20
frontend/src/i18n/locales/zh-TW.ts

@@ -3275,9 +3275,6 @@ export default {
     measuredWeight: '稱量重量',
     measuredWeight: '稱量重量',
     spoolName: '料盤',
     spoolName: '料盤',
     costPerKg: '每公斤成本',
     costPerKg: '每公斤成本',
-    storageLocation: '存放位置',
-    storageLocationPlaceholder: '例如:貨架A,抽屜1',
-    openInInventory: '在庫存中查看',
     measuredWeightError: '稱量重量必須在 {{min}}g 到 {{max}}g 之間。',
     measuredWeightError: '稱量重量必須在 {{min}}g 到 {{max}}g 之間。',
     slicerFilament: '切片耗材',
     slicerFilament: '切片耗材',
     slicerFilamentName: '切片預設名稱',
     slicerFilamentName: '切片預設名稱',
@@ -3316,27 +3313,11 @@ export default {
     stock: '庫存',
     stock: '庫存',
     configured: '已設定',
     configured: '已設定',
     spoolsCreated: '已建立 {{count}} 個耗材',
     spoolsCreated: '已建立 {{count}} 個耗材',
-    spoolsPartiallyCreated: '已建立 {{created}} / {{total}} 個耗材(部分失敗)',
     spoolCreated: '耗材已建立',
     spoolCreated: '耗材已建立',
     spoolUpdated: '耗材已更新',
     spoolUpdated: '耗材已更新',
     spoolDeleted: '耗材已刪除',
     spoolDeleted: '耗材已刪除',
-    deepLinkSpoolNotFound: '找不到耗材',
-    deepLinkFetchFailed: '無法載入耗材 — 請重試',
     spoolArchived: '耗材已歸檔',
     spoolArchived: '耗材已歸檔',
     spoolRestored: '耗材已恢復',
     spoolRestored: '耗材已恢復',
-    kProfileSaveFailed: 'K值設定檔設定無法儲存',
-    syncWeightSpoolNotFound: '找不到耗材 — 可能已被刪除',
-    syncWeightSpoolmanUnreachable: 'Spoolman 無法存取 — 請稍後再試',
-    syncWeightFailed: '重量同步失敗',
-    spoolmanUnreachable: 'Spoolman 無法存取 — 請稍後再試',
-    deleteSpoolNotFound: '找不到耗材 — 可能已被刪除',
-    deleteFailed: '刪除耗材失敗',
-    archiveSpoolNotFound: '找不到耗材 — 可能已被刪除',
-    archiveFailed: '歸檔耗材失敗',
-    restoreSpoolNotFound: '找不到耗材 — 可能已被刪除',
-    restoreFailed: '恢復耗材失敗',
-    saveFailed: '儲存變更失敗',
-    tagClearFailed: '清除標籤失敗',
     deleteConfirm: '確定要刪除此耗材嗎?此操作無法復原。',
     deleteConfirm: '確定要刪除此耗材嗎?此操作無法復原。',
     archiveConfirm: '確定要歸檔此耗材嗎?',
     archiveConfirm: '確定要歸檔此耗材嗎?',
     advancedSettings: '進階設定',
     advancedSettings: '進階設定',
@@ -5021,7 +5002,6 @@ export default {
       creating: '建立中...',
       creating: '建立中...',
       spoolCreated: '耗材已建立!準備寫入。',
       spoolCreated: '耗材已建立!準備寫入。',
       createFailed: '建立耗材失敗',
       createFailed: '建立耗材失敗',
-      incompleteDataWarning: '已使用不完整的Spoolman資料寫入標籤',
     },
     },
     quickMenu: {
     quickMenu: {
       printerPower: '印表機電源',
       printerPower: '印表機電源',

+ 69 - 147
frontend/src/pages/InventoryPage.tsx

@@ -1,5 +1,4 @@
-import { useState, useMemo, useEffect, useRef, useCallback, type ReactNode } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { useState, useMemo, useEffect, type ReactNode } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
@@ -8,7 +7,7 @@ import {
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw,
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw,
 } from 'lucide-react';
 } from 'lucide-react';
-import { api, spoolbuddyApi, ApiError } from '../api/client';
+import { api, spoolbuddyApi } from '../api/client';
 import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
 import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { SpoolFormModal } from '../components/SpoolFormModal';
 import { SpoolFormModal } from '../components/SpoolFormModal';
@@ -49,7 +48,6 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'brand', label: 'Brand', visible: true },
   { id: 'brand', label: 'Brand', visible: true },
   { id: 'slicer_filament', label: 'Slicer Filament', visible: false },
   { id: 'slicer_filament', label: 'Slicer Filament', visible: false },
   { id: 'location', label: 'Location', visible: true },
   { id: 'location', label: 'Location', visible: true },
-  { id: 'storage_location', label: 'Storage Location', visible: false },
   { id: 'label_weight', label: 'Label', visible: true },
   { id: 'label_weight', label: 'Label', visible: true },
   { id: 'net', label: 'Net', visible: true },
   { id: 'net', label: 'Net', visible: true },
   { id: 'gross', label: 'Gross', visible: false },
   { id: 'gross', label: 'Gross', visible: false },
@@ -147,7 +145,6 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   brand: (t) => t('inventory.brand'),
   brand: (t) => t('inventory.brand'),
   slicer_filament: (t) => t('inventory.slicerFilament'),
   slicer_filament: (t) => t('inventory.slicerFilament'),
   location: () => 'Location',
   location: () => 'Location',
-  storage_location: (t) => t('inventory.storageLocation'),
   label_weight: (t) => t('inventory.labelWeight'),
   label_weight: (t) => t('inventory.labelWeight'),
   net: (t) => t('inventory.net'),
   net: (t) => t('inventory.net'),
   gross: () => 'Gross',
   gross: () => 'Gross',
@@ -220,14 +217,6 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
       </span>
       </span>
     );
     );
   },
   },
-  storage_location: ({ spool }) => {
-    if (!spool.storage_location) return <span className="text-sm text-bambu-gray">-</span>;
-    return (
-      <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-500/20 text-blue-400">
-        {spool.storage_location}
-      </span>
-    );
-  },
   label_weight: ({ spool }) => (
   label_weight: ({ spool }) => (
     <span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
     <span className="text-sm text-white">{formatWeight(spool.label_weight)}</span>
   ),
   ),
@@ -380,7 +369,6 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
     const label = a.ams_label ? ` (${a.ams_label})` : '';
     const label = a.ams_label ? ` (${a.ams_label})` : '';
     return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`;
     return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`;
   },
   },
-  storage_location: (s) => (s.storage_location || '').toLowerCase(),
   label_weight: (s) => s.label_weight,
   label_weight: (s) => s.label_weight,
   net: (s) => Math.max(0, s.label_weight - s.weight_used),
   net: (s) => Math.max(0, s.label_weight - s.weight_used),
   gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight,
   gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight,
@@ -419,28 +407,74 @@ function saveSortState(state: SortState) {
   } catch { /* ignore */ }
   } catch { /* ignore */ }
 }
 }
 
 
-// Wrapper: detects Spoolman mode and passes it to the shared inventory UI
+// Wrapper: when Spoolman is enabled, embed its UI; otherwise show internal inventory
 export default function InventoryPageRouter() {
 export default function InventoryPageRouter() {
+  const { t } = useTranslation();
   const { data: spoolmanSettings } = useQuery({
   const { data: spoolmanSettings } = useQuery({
     queryKey: ['spoolman-settings'],
     queryKey: ['spoolman-settings'],
     queryFn: api.getSpoolmanSettings,
     queryFn: api.getSpoolmanSettings,
     staleTime: 5 * 60 * 1000,
     staleTime: 5 * 60 * 1000,
   });
   });
 
 
-  const spoolmanModeReady = spoolmanSettings !== undefined;
-  const spoolmanMode =
-    spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
+  if (spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url) {
+    const spoolmanUrl = spoolmanSettings.spoolman_url.replace(/\/+$/, '');
+    // Browsers block HTTP iframes inside HTTPS parents (mixed-content rule,
+    // independent of CSP). Spoolman must be reachable over the same protocol
+    // as Bambuddy. Surfacing a targeted error here beats the silent blank
+    // iframe users otherwise see. See issue #1096.
+    const bambuddyIsHttps = window.location.protocol === 'https:';
+    const spoolmanIsHttp = spoolmanUrl.toLowerCase().startsWith('http://');
+    if (bambuddyIsHttps && spoolmanIsHttp) {
+      return (
+        <div className="p-6 max-w-3xl mx-auto">
+          <div className="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-5">
+            <div className="flex items-start gap-3">
+              <AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
+              <div className="flex-1 min-w-0 space-y-2 text-sm">
+                <p className="font-semibold text-amber-900 dark:text-amber-100">
+                  {t('inventory.spoolmanMixedContentTitle')}
+                </p>
+                <p className="text-amber-800 dark:text-amber-200">
+                  {t('inventory.spoolmanMixedContentBody')}
+                </p>
+                <ul className="list-disc pl-5 space-y-1 text-amber-800 dark:text-amber-200">
+                  <li>{t('inventory.spoolmanMixedContentFixReverseProxy')}</li>
+                  <li>{t('inventory.spoolmanMixedContentFixOpenNewTab')}</li>
+                </ul>
+                <div className="flex flex-wrap gap-2 pt-2">
+                  <a
+                    href={`${spoolmanUrl}/spool`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded bg-amber-600 hover:bg-amber-700 text-white"
+                  >
+                    {t('inventory.spoolmanOpenInNewTab')}
+                  </a>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      );
+    }
+    return (
+      <iframe
+        src={`${spoolmanUrl}/spool`}
+        className="h-full w-full border-0"
+        title="Spoolman"
+        sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
+      />
+    );
+  }
 
 
-  return <InventoryPage spoolmanMode={spoolmanMode} spoolmanModeReady={spoolmanModeReady} />;
+  return <InventoryPage />;
 }
 }
 
 
-function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spoolmanMode?: boolean; spoolmanModeReady?: boolean }) {
+function InventoryPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const [searchParams, setSearchParams] = useSearchParams();
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
-  const deepLinkHandled = useRef(false);
   const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
   const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
 
 
   // Filter state
   // Filter state
@@ -482,83 +516,12 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
 
 
   const dateFormat: DateFormat = settings?.date_format || 'system';
   const dateFormat: DateFormat = settings?.date_format || 'system';
 
 
-  // Query key and fetch function differ based on data source
-  const spoolsQueryKey = spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'];
   const { data: spools, isLoading } = useQuery({
   const { data: spools, isLoading } = useQuery({
-    queryKey: spoolsQueryKey,
-    queryFn: () =>
-      spoolmanMode ? api.getSpoolmanInventorySpools(true) : api.getSpools(true),
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
     refetchInterval: 30000,
     refetchInterval: 30000,
   });
   });
 
 
-  // Deep-link: open edit modal for ?spool=<id>
-  // Prefer the already-loaded spool list (no extra API call); fall back to a
-  // targeted fetch for the rare case where the full list hasn't arrived yet.
-  const _rawSpoolParam = searchParams.get('spool');
-  // Only accept strings of digits representing a positive integer — guards against
-  // NaN (Number('abc')), 0, negatives, and floats like '1.5' that would produce
-  // an invalid path parameter and trigger unnecessary 422 responses from the API.
-  const deepLinkSpoolId =
-    _rawSpoolParam && /^\d+$/.test(_rawSpoolParam) && Number(_rawSpoolParam) > 0
-      ? Number(_rawSpoolParam)
-      : null;
-  const deepLinkInList = spools?.find((s) => s.id === deepLinkSpoolId) ?? null;
-
-  const clearDeepLinkParam = useCallback(() => {
-    deepLinkHandled.current = true;
-    setSearchParams((prev) => { prev.delete('spool'); return prev; }, { replace: true });
-  }, [setSearchParams]);
-
-  // Targeted fetch — only fires when mode is known and spool isn't in the list yet
-  const { data: deepLinkSpool, isError: deepLinkFetchFailed, error: deepLinkError } = useQuery({
-    queryKey: spoolmanMode
-      ? ['spoolman-inventory-spool', deepLinkSpoolId]
-      : ['inventory-spool', deepLinkSpoolId],
-    queryFn: () =>
-      spoolmanMode
-        ? api.getSpoolmanInventorySpool(deepLinkSpoolId!)
-        : api.getSpool(deepLinkSpoolId!),
-    enabled: spoolmanModeReady && deepLinkSpoolId !== null && deepLinkInList === null,
-    staleTime: Infinity,
-    retry: (failureCount, error) =>
-      failureCount < 2 && !(error instanceof ApiError && error.status === 404),
-  });
-
-  useEffect(() => {
-    if (deepLinkHandled.current) return;
-
-    // Case 1: spool is already in the fetched list
-    if (spoolmanModeReady && deepLinkSpoolId && deepLinkInList) {
-      clearDeepLinkParam();
-      setFormModal({ spool: deepLinkInList });
-      return;
-    }
-
-    // Case 2: spool was fetched individually
-    if (deepLinkSpool) {
-      clearDeepLinkParam();
-      setFormModal({ spool: deepLinkSpool });
-      return;
-    }
-
-    // Case 3: fetch failed
-    if (deepLinkFetchFailed) {
-      clearDeepLinkParam();
-      const is404 = deepLinkError instanceof ApiError && deepLinkError.status === 404;
-      showToast(t(is404 ? 'inventory.deepLinkSpoolNotFound' : 'inventory.deepLinkFetchFailed'), 'error');
-    }
-  }, [
-    spoolmanModeReady,
-    deepLinkSpoolId,
-    deepLinkInList,
-    deepLinkSpool,
-    deepLinkFetchFailed,
-    deepLinkError,
-    clearDeepLinkParam,
-    showToast,
-    t,
-  ]);
-
   const { data: assignments } = useQuery({
   const { data: assignments } = useQuery({
     queryKey: ['spool-assignments'],
     queryKey: ['spool-assignments'],
     queryFn: () => api.getAssignments(),
     queryFn: () => api.getAssignments(),
@@ -571,76 +534,38 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   });
   });
 
 
   const deleteMutation = useMutation({
   const deleteMutation = useMutation({
-    mutationFn: (id: number) =>
-      spoolmanMode ? api.deleteSpoolmanInventorySpool(id) : api.deleteSpool(id),
+    mutationFn: (id: number) => api.deleteSpool(id),
     onSuccess: () => {
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.spoolDeleted'), 'success');
       showToast(t('inventory.spoolDeleted'), 'success');
     },
     },
-    onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 404) {
-        showToast(t('inventory.deleteSpoolNotFound'), 'error');
-      } else if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else {
-        showToast(t('inventory.deleteFailed'), 'error');
-      }
-    },
   });
   });
 
 
   const archiveMutation = useMutation({
   const archiveMutation = useMutation({
-    mutationFn: (id: number) =>
-      spoolmanMode ? api.archiveSpoolmanInventorySpool(id) : api.archiveSpool(id),
+    mutationFn: (id: number) => api.archiveSpool(id),
     onSuccess: () => {
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.spoolArchived'), 'success');
       showToast(t('inventory.spoolArchived'), 'success');
     },
     },
-    onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 404) {
-        showToast(t('inventory.archiveSpoolNotFound'), 'error');
-      } else if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else {
-        showToast(t('inventory.archiveFailed'), 'error');
-      }
-    },
   });
   });
 
 
   const restoreMutation = useMutation({
   const restoreMutation = useMutation({
-    mutationFn: (id: number) =>
-      spoolmanMode ? api.restoreSpoolmanInventorySpool(id) : api.restoreSpool(id),
+    mutationFn: (id: number) => api.restoreSpool(id),
     onSuccess: () => {
     onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.spoolRestored'), 'success');
       showToast(t('inventory.spoolRestored'), 'success');
     },
     },
-    onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 404) {
-        showToast(t('inventory.restoreSpoolNotFound'), 'error');
-      } else if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else {
-        showToast(t('inventory.restoreFailed'), 'error');
-      }
-    },
   });
   });
 
 
   const handleSyncWeight = async (spool: InventorySpool) => {
   const handleSyncWeight = async (spool: InventorySpool) => {
     if (spool.last_scale_weight == null) return;
     if (spool.last_scale_weight == null) return;
     try {
     try {
-      if (spoolmanMode) {
-        await api.syncSpoolmanSpoolWeight(spool.id, spool.last_scale_weight);
-      } else {
-        await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight);
-      }
-      queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
+      await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight);
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       const spoolName = [spool.brand, spool.material, spool.color_name].filter(Boolean).join(' ');
       const spoolName = [spool.brand, spool.material, spool.color_name].filter(Boolean).join(' ');
       showToast(`Synced "${spoolName}" to scale weight`, 'success');
       showToast(`Synced "${spoolName}" to scale weight`, 'success');
-    } catch (e) {
-      const is404 = e instanceof ApiError && e.status === 404;
-      const is503 = e instanceof ApiError && e.status === 503;
-      if (is404) showToast(t('inventory.syncWeightSpoolNotFound'), 'error');
-      else if (is503) showToast(t('inventory.syncWeightSpoolmanUnreachable'), 'error');
-      else showToast(t('inventory.syncWeightFailed'), 'error');
+    } catch {
+      showToast('Failed to sync weight', 'error');
     }
     }
   };
   };
 
 
@@ -778,8 +703,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         s.color_name?.toLowerCase().includes(q) ||
         s.color_name?.toLowerCase().includes(q) ||
         s.subtype?.toLowerCase().includes(q) ||
         s.subtype?.toLowerCase().includes(q) ||
         s.note?.toLowerCase().includes(q) ||
         s.note?.toLowerCase().includes(q) ||
-        s.slicer_filament_name?.toLowerCase().includes(q) ||
-        s.storage_location?.toLowerCase().includes(q)
+        s.slicer_filament_name?.toLowerCase().includes(q)
       );
       );
     }
     }
 
 
@@ -1588,8 +1512,6 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           onClose={() => setFormModal(null)}
           onClose={() => setFormModal(null)}
           spool={formModal.spool}
           spool={formModal.spool}
           currencySymbol={currencySymbol}
           currencySymbol={currencySymbol}
-          spoolmanMode={spoolmanMode}
-          spoolsQueryKey={spoolsQueryKey}
         />
         />
       )}
       )}
 
 

+ 17 - 12
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -52,13 +52,6 @@ function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
   );
   );
 }
 }
 
 
-// Renders inventory directly via React instead of embedding Spoolman's own UI in an
-// iframe. The iframe approach was dropped because this component lives inside the
-// SpoolBuddy shell and already has direct access to the same auth/query context,
-// making the iframe an unnecessary dependency on the Spoolman server being reachable
-// from the browser. No feature flag guards this: the internal UI is strictly superior
-// (works offline, supports local spools) and the raw Spoolman URL remains accessible
-// directly from the Settings page for users who want it.
 export function SpoolBuddyInventoryPage() {
 export function SpoolBuddyInventoryPage() {
   const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
   const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -73,12 +66,9 @@ export function SpoolBuddyInventoryPage() {
     staleTime: 5 * 60 * 1000,
     staleTime: 5 * 60 * 1000,
   });
   });
 
 
-  const spoolmanMode = spoolmanSettings?.spoolman_enabled === 'true' && !!spoolmanSettings?.spoolman_url;
-
   const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({
   const { data: spools = [], isLoading, refetch: refetchSpools } = useQuery({
-    queryKey: spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'],
-    queryFn: () => spoolmanMode ? api.getSpoolmanInventorySpools(false) : api.getSpools(false),
-    enabled: spoolmanSettings !== undefined,
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(false),
     refetchInterval: 30000,
     refetchInterval: 30000,
   });
   });
 
 
@@ -138,6 +128,21 @@ export function SpoolBuddyInventoryPage() {
     });
     });
   }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
   }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
 
 
+  // Spoolman iframe mode
+  const spoolmanEnabled = spoolmanSettings?.spoolman_enabled === 'true' && spoolmanSettings?.spoolman_url;
+  if (spoolmanEnabled) {
+    return (
+      <div className="h-full flex flex-col">
+        <iframe
+          src={`${spoolmanSettings.spoolman_url.replace(/\/+$/, '')}/spool`}
+          className="flex-1 w-full border-0"
+          title="Spoolman"
+          sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
+        />
+      </div>
+    );
+  }
+
   return (
   return (
     <div className="h-full flex flex-col">
     <div className="h-full flex flex-col">
       {/* Search + filter pills */}
       {/* Search + filter pills */}

+ 1 - 8
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
 import { useOutletContext } from 'react-router-dom';
 import { useOutletContext } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { useToast } from '../../contexts/ToastContext';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import {
 import {
   api,
   api,
@@ -35,7 +34,6 @@ const SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC',
 
 
 export function SpoolBuddyWriteTagPage() {
 export function SpoolBuddyWriteTagPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { showToast } = useToast();
   const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
   const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
 
 
   const [activeTab, setActiveTab] = useState<Tab>('existing');
   const [activeTab, setActiveTab] = useState<Tab>('existing');
@@ -165,12 +163,7 @@ export function SpoolBuddyWriteTagPage() {
     setWriteStatus('writing');
     setWriteStatus('writing');
     setWriteMessage(t('spoolbuddy.writeTag.waiting', 'Waiting for SpoolBuddy...'));
     setWriteMessage(t('spoolbuddy.writeTag.waiting', 'Waiting for SpoolBuddy...'));
     try {
     try {
-      const resp = await spoolbuddyApi.writeTag(device.device_id, selectedSpool.id);
-      if (resp?.warnings?.length) {
-        for (const w of resp.warnings) {
-          showToast(w, 'warning');
-        }
-      }
+      await spoolbuddyApi.writeTag(device.device_id, selectedSpool.id);
     } catch {
     } catch {
       setWriteStatus('error');
       setWriteStatus('error');
       setWriteMessage(t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'));
       setWriteMessage(t('spoolbuddy.writeTag.queueFailed', 'Failed to queue write command'));

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů