Просмотр исходного кода

Merge branch '0.2.2b1' into statistic-timeframe

MartinNYHC 6 месяцев назад
Родитель
Сommit
449dd24f62

+ 3 - 0
CHANGELOG.md

@@ -14,6 +14,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 
 
 ### Fixed
 ### Fixed
+- **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.
+- **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.
 - **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
 - **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.
 - **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
 - **Queue Returns 500 When Cancelled Print Exists** ([#558](https://github.com/maziggy/bambuddy/issues/558)) — When a print was cancelled mid-print, the MQTT completion handler stored status `"aborted"` on the queue item, but the response schema only accepts `"pending"`, `"printing"`, `"completed"`, `"failed"`, `"skipped"`, or `"cancelled"`. Listing all queue items hit a Pydantic validation error on the invalid status, returning a 500 error. Filtering by a specific status (e.g. "pending") excluded the bad row and worked fine. Now normalises `"aborted"` to `"cancelled"` before storing. A startup fixup also converts any existing `"aborted"` rows.
 - **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.
 - **Tests Send Real Maintenance Notifications** — Tests that call `on_print_complete(status="completed")` created background `asyncio` tasks (maintenance check, smart plug, notifications) that outlived the test's mock context. When the event loop processed these orphaned tasks, `async_session` was no longer patched and they queried the real production database — finding real printers with maintenance due and real notification providers, then sending real notifications. Tests now cancel spawned background tasks before the mock context exits.
@@ -36,6 +38,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
 - **SpoolBuddy Assign to AMS Redesign** — The "Assign to AMS" sub-modal (opened from the spool card) is now a full-screen overlay that reuses the `AmsUnitCard` component from the AMS page. Regular AMS units display in a 2-column grid with the same spool visualization, fill bars, and material labels. AMS-HT and external slots (Ext / Ext-L / Ext-R on dual-nozzle printers) appear in a compact horizontal row below. Clicking any slot auto-configures the filament via a single `assignSpool` API call — the backend handles both the DB assignment and MQTT configuration. The printer selector was removed from the modal since the top bar already provides printer selection. Dual-nozzle printers show L/R nozzle badges on each AMS unit.
 - **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
 - **Filament ID Conversion Utility** — Extracted filament_id ↔ setting_id conversion logic into a shared utility (`backend/app/utils/filament_ids.py`). The `assign_spool` endpoint now normalizes `slicer_filament` (which can be stored in either filament_id format like "GFL05" or setting_id format like "GFSL05_07") into the correct `tray_info_idx` and `setting_id` for the MQTT command. Previously `setting_id` was always sent as empty string, which could cause BambuStudio to not resolve the filament preset for the AMS slot.
 - **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
 - **Updates Card Separates Firmware and Software Settings** — The Updates card on the Settings page mixed printer firmware and Bambuddy software update toggles with no visual grouping. Now splits the card into two labeled sections ("Printer Firmware" and "Bambuddy Software") separated by a divider, making it clear which toggles control what.
+- **SpoolBuddy Test Coverage** — Added integration tests for all 12 SpoolBuddy API endpoints (21 backend tests covering device registration/re-registration, heartbeat status and pending commands, NFC tag scan/match/removal, scale reading broadcast, spool weight calculation, and scale calibration including tare, set-factor, and zero-delta error handling) and component tests for the three main SpoolBuddy frontend components (20 frontend tests covering WeightDisplay weight formatting and status indicators, SpoolInfoCard spool info rendering and action callbacks, UnknownTagCard tag display, and TagDetectedModal open/close/escape behavior with known and unknown spool views).
 
 
 ## [0.2.1] - 2026-02-27
 ## [0.2.1] - 2026-02-27
 
 

+ 110 - 30
backend/app/api/routes/inventory.py

@@ -732,17 +732,15 @@ async def assign_spool(
         if client:
         if client:
             # Build filament setting from spool data
             # Build filament setting from spool data
             tray_type = spool.material
             tray_type = spool.material
-            tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
+            tray_sub_brands = (
+                f"{spool.brand} {spool.material} {spool.subtype}".strip()
+                if spool.brand
+                else f"{spool.material} {spool.subtype}"
+                if spool.subtype
+                else spool.material
+            )
             tray_color = spool.rgba or "FFFFFFFF"
             tray_color = spool.rgba or "FFFFFFFF"
-            tray_info_idx, setting_id = normalize_slicer_filament(spool.slicer_filament)
-
-            # Resolve tray_info_idx for the MQTT command.
-            # Priority:
-            #   1. Use the spool's own slicer_filament if set (including
-            #      cloud-synced custom presets like PFUS* / P*).
-            #   2. Reuse the slot's existing tray_info_idx if it's a specific
-            #      (non-generic) preset for the same material.
-            #   3. Fall back to a generic Bambu filament ID.
+
             _GENERIC_IDS = {
             _GENERIC_IDS = {
                 "PLA": "GFL99",
                 "PLA": "GFL99",
                 "PETG": "GFG99",
                 "PETG": "GFG99",
@@ -761,26 +759,108 @@ async def assign_spool(
             }
             }
             _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
             _GENERIC_ID_VALUES = set(_GENERIC_IDS.values())
 
 
-            if tray_info_idx:
-                logger.info("Spool assign: using spool's slicer_filament=%r", tray_info_idx)
-            elif (
-                current_tray_info_idx
-                and current_tray_info_idx not in _GENERIC_ID_VALUES
-                and fingerprint_type
-                and fingerprint_type.upper() == tray_type.upper()
-            ):
-                logger.info(
-                    "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
-                    current_tray_info_idx,
-                    tray_type,
-                )
-                tray_info_idx = current_tray_info_idx
-            elif tray_type:
-                material = tray_type.upper().strip()
-                generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
-                if generic:
-                    logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
-                    tray_info_idx = generic
+            # Resolve tray_info_idx + setting_id for the MQTT command.
+            # Three sources in priority order:
+            #   1. Cloud profile (if cloud connected) — resolve filament_id
+            #      from setting_id via cloud API
+            #   2. Local profile — use generic filament ID for material
+            #   3. Hard-coded fallback — generic Bambu filament IDs
+            tray_info_idx = ""
+            setting_id = ""
+            sf = spool.slicer_filament or ""
+
+            if sf:
+                # Check if it's a cloud preset (GFS*, PFUS*, or GF* official)
+                base_sf = sf.split("_")[0] if "_" in sf else sf
+                if base_sf.startswith("GFS") or base_sf.startswith("PFUS"):
+                    # Cloud setting_id — need to resolve real filament_id
+                    # Use base_sf (version suffix stripped) for cloud API + MQTT
+                    setting_id = base_sf
+                    try:
+                        from backend.app.services.bambu_cloud import get_cloud_service
+
+                        cloud = get_cloud_service()
+                        if cloud.is_authenticated:
+                            detail = await cloud.get_setting_detail(base_sf)
+                            if detail.get("filament_id"):
+                                tray_info_idx = detail["filament_id"]
+                                logger.info(
+                                    "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
+                                    tray_info_idx,
+                                    sf,
+                                )
+                                # Use cloud preset name for tray_sub_brands if available
+                                cloud_name = detail.get("name", "")
+                                if cloud_name:
+                                    tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                            elif detail.get("base_id"):
+                                # Derive from base_id (e.g. "GFSL05" → "GFL05")
+                                bid = detail["base_id"].split("_")[0]
+                                if bid.startswith("GFS") and len(bid) >= 5:
+                                    tray_info_idx = f"GF{bid[3:]}"
+                                else:
+                                    tray_info_idx = bid
+                                logger.info(
+                                    "Spool assign: derived filament_id=%r from base_id=%r",
+                                    tray_info_idx,
+                                    detail["base_id"],
+                                )
+                    except Exception as e:
+                        logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
+
+                    if not tray_info_idx:
+                        # Cloud lookup failed — use normalize as fallback
+                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+                elif base_sf.startswith("GF"):
+                    # Official Bambu filament_id (e.g. "GFL05")
+                    tray_info_idx, setting_id = normalize_slicer_filament(sf)
+                    logger.info("Spool assign: using official filament_id=%r", tray_info_idx)
+                else:
+                    # Could be a local preset ID or material type — try local DB
+                    try:
+                        local_id = int(sf)
+                        from backend.app.models.local_preset import LocalPreset as LP
+
+                        lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
+                        lp = lp_result.scalar_one_or_none()
+                        if lp:
+                            mat = (lp.filament_type or spool.material or "").upper().strip()
+                            tray_info_idx = (
+                                _GENERIC_IDS.get(mat) or _GENERIC_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
+                            )
+                            # Use local preset name for tray_sub_brands
+                            if lp.name:
+                                tray_sub_brands = lp.name.split("@")[0].strip()
+                            logger.info(
+                                "Spool assign: local preset %d, material=%r, tray_info_idx=%r",
+                                local_id,
+                                mat,
+                                tray_info_idx,
+                            )
+                    except (ValueError, TypeError):
+                        # Not a numeric ID — treat as material type string
+                        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+
+            if not tray_info_idx:
+                # Fallback: reuse slot's existing tray_info_idx or generic ID
+                if (
+                    current_tray_info_idx
+                    and current_tray_info_idx not in _GENERIC_ID_VALUES
+                    and fingerprint_type
+                    and fingerprint_type.upper() == tray_type.upper()
+                ):
+                    logger.info(
+                        "Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
+                        current_tray_info_idx,
+                        tray_type,
+                    )
+                    tray_info_idx = current_tray_info_idx
+                elif tray_type:
+                    material = tray_type.upper().strip()
+                    generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
+                    if generic:
+                        logger.info("Spool assign: falling back to generic %r for material %r", generic, tray_type)
+                        tray_info_idx = generic
 
 
             # Temperature: use spool overrides if set, else material defaults
             # Temperature: use spool overrides if set, else material defaults
             temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
             temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))

+ 472 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -0,0 +1,472 @@
+"""Integration tests for SpoolBuddy API endpoints."""
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+
+API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+def device_factory(db_session: AsyncSession):
+    """Factory to create SpoolBuddyDevice records."""
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        n = _counter[0]
+        defaults = {
+            "device_id": f"sb-{n:04d}",
+            "hostname": f"spoolbuddy-{n}",
+            "ip_address": f"10.0.0.{n}",
+            "firmware_version": "1.0.0",
+            "has_nfc": True,
+            "has_scale": True,
+            "tare_offset": 0,
+            "calibration_factor": 1.0,
+            "last_seen": datetime.now(timezone.utc),
+        }
+        defaults.update(kwargs)
+        device = SpoolBuddyDevice(**defaults)
+        db_session.add(device)
+        await db_session.commit()
+        await db_session.refresh(device)
+        return device
+
+    return _create
+
+
+@pytest.fixture
+def spool_factory(db_session: AsyncSession):
+    """Factory to create Spool records."""
+    _counter = [0]
+
+    async def _create(**kwargs):
+        _counter[0] += 1
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Polymaker",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "weight_used": 0,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+# ============================================================================
+# Device endpoints
+# ============================================================================
+
+
+class TestDeviceEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_register_new_device(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/register",
+                json={
+                    "device_id": "sb-new",
+                    "hostname": "spoolbuddy-new",
+                    "ip_address": "10.0.0.99",
+                    "firmware_version": "1.2.0",
+                },
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["device_id"] == "sb-new"
+        assert data["hostname"] == "spoolbuddy-new"
+        assert data["online"] is True
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_re_register_existing_device(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(
+            device_id="sb-exist",
+            tare_offset=12345,
+            calibration_factor=0.0042,
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/register",
+                json={
+                    "device_id": "sb-exist",
+                    "hostname": "updated-host",
+                    "ip_address": "10.0.0.200",
+                    "firmware_version": "2.0.0",
+                },
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["id"] == device.id
+        assert data["hostname"] == "updated-host"
+        assert data["ip_address"] == "10.0.0.200"
+        assert data["firmware_version"] == "2.0.0"
+        # Calibration preserved on re-register
+        assert data["tare_offset"] == 12345
+        assert data["calibration_factor"] == pytest.approx(0.0042)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_devices_empty(self, async_client: AsyncClient):
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        assert resp.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_devices(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-a", hostname="alpha")
+        await device_factory(device_id="sb-b", hostname="beta")
+
+        resp = await async_client.get(f"{API}/devices")
+        assert resp.status_code == 200
+        devices = resp.json()
+        assert len(devices) == 2
+        hostnames = {d["hostname"] for d in devices}
+        assert hostnames == {"alpha", "beta"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
+        device = await device_factory(device_id="sb-hb")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-hb/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 600},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == device.tare_offset
+        assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_returns_pending_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-cmd", pending_command="tare")
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-cmd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+
+        assert resp.status_code == 200
+        assert resp.json()["pending_command"] == "tare"
+
+        # Second heartbeat should have no pending command (cleared)
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp2 = await async_client.post(
+                f"{API}/devices/sb-cmd/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 20},
+            )
+
+        assert resp2.json()["pending_command"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_unknown_device_404(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/nonexistent/heartbeat",
+                json={"nfc_ok": False, "scale_ok": False, "uptime_s": 0},
+            )
+
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
+        # Create device with last_seen far in the past (offline)
+        await device_factory(
+            device_id="sb-offline",
+            last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-offline/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 5},
+            )
+
+        assert resp.status_code == 200
+        # Should broadcast online since device was offline
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-offline"
+
+
+# ============================================================================
+# NFC endpoints
+# ============================================================================
+
+
+class TestNfcEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_matched(self, async_client: AsyncClient, spool_factory):
+        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) as mock_lookup,
+        ):
+            mock_ws.broadcast = AsyncMock()
+            mock_lookup.return_value = mock_spool
+
+            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
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["spool"]["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_scanned_unmatched(self, async_client: AsyncClient):
+        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) as mock_lookup,
+        ):
+            mock_ws.broadcast = AsyncMock()
+            mock_lookup.return_value = None
+
+            resp = await async_client.post(
+                f"{API}/nfc/tag-scanned",
+                json={"device_id": "sb-1", "tag_uid": "DEADBEEF"},
+            )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["matched"] is False
+        assert data["spool_id"] is None
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_unknown_tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_removed(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/tag-removed",
+                json={"device_id": "sb-1", "tag_uid": "AABB1122"},
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_tag_removed"
+        assert msg["device_id"] == "sb-1"
+        assert msg["tag_uid"] == "AABB1122"
+
+
+# ============================================================================
+# Scale endpoints
+# ============================================================================
+
+
+class TestScaleEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_scale_reading_broadcast(self, async_client: AsyncClient):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/scale/reading",
+                json={
+                    "device_id": "sb-1",
+                    "weight_grams": 823.5,
+                    "stable": True,
+                    "raw_adc": 456789,
+                },
+            )
+
+        assert resp.status_code == 200
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_weight"
+        assert msg["device_id"] == "sb-1"
+        assert msg["weight_grams"] == 823.5
+        assert msg["stable"] is True
+        assert msg["raw_adc"] == 456789
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_calculates_correctly(self, async_client: AsyncClient, spool_factory):
+        # label=1000g, core=250g, scale reads 750g
+        # net_filament = max(0, 750 - 250) = 500
+        # weight_used = max(0, 1000 - 500) = 500
+        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
+        data = resp.json()
+        assert data["weight_used"] == 500
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_full_spool(self, async_client: AsyncClient, spool_factory):
+        # label=1000g, core=250g, scale reads 1250g (full spool)
+        # net_filament = max(0, 1250 - 250) = 1000
+        # weight_used = max(0, 1000 - 1000) = 0
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=200)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 1250},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["weight_used"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": 99999, "weight_grams": 500},
+        )
+        assert resp.status_code == 404
+
+
+# ============================================================================
+# Calibration endpoints
+# ============================================================================
+
+
+class TestCalibrationEndpoints:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tare_queues_command(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-tare")
+
+        resp = await async_client.post(f"{API}/devices/sb-tare/calibration/tare", json={})
+        assert resp.status_code == 200
+        assert resp.json()["status"] == "ok"
+
+        # Verify pending_command via heartbeat
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            hb = await async_client.post(
+                f"{API}/devices/sb-tare/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 1},
+            )
+        assert hb.json()["pending_command"] == "tare"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tare_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.post(f"{API}/devices/ghost/calibration/tare", json={})
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_tare_offset(self, async_client: AsyncClient, device_factory):
+        await device_factory(device_id="sb-st", calibration_factor=0.005)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-st/calibration/set-tare",
+            json={"tare_offset": 54321},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == 54321
+        assert data["calibration_factor"] == pytest.approx(0.005)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_calibration_factor(self, async_client: AsyncClient, device_factory):
+        # known_weight=200g, raw_adc=50000, tare=10000 → factor=200/(50000-10000)=0.005
+        await device_factory(device_id="sb-cf", tare_offset=10000)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-cf/calibration/set-factor",
+            json={"known_weight_grams": 200, "raw_adc": 50000},
+        )
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["calibration_factor"] == pytest.approx(0.005)
+        assert data["tare_offset"] == 10000
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_calibration_factor_zero_delta_400(self, async_client: AsyncClient, device_factory):
+        # raw_adc == tare_offset → delta is 0 → 400 error
+        await device_factory(device_id="sb-zero", tare_offset=5000)
+
+        resp = await async_client.post(
+            f"{API}/devices/sb-zero/calibration/set-factor",
+            json={"known_weight_grams": 100, "raw_adc": 5000},
+        )
+
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_calibration(self, async_client: AsyncClient, device_factory):
+        await device_factory(
+            device_id="sb-gcal",
+            tare_offset=11111,
+            calibration_factor=0.0042,
+        )
+
+        resp = await async_client.get(f"{API}/devices/sb-gcal/calibration")
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["tare_offset"] == 11111
+        assert data["calibration_factor"] == pytest.approx(0.0042)

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

@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+
+const mockUpdateSpoolWeight = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
+  },
+}));
+
+const mockSpool: MatchedSpool = {
+  id: 42,
+  tag_uid: 'AABBCCDD11223344',
+  material: 'PLA',
+  subtype: 'Matte',
+  color_name: 'Jade White',
+  rgba: 'E8F5E9FF',
+  brand: 'Bambu',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 200,
+};
+
+describe('SpoolInfoCard', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
+  });
+
+  it('renders spool material, brand, color name', () => {
+    render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);
+
+    expect(screen.getByText('Jade White')).toBeInTheDocument();
+    expect(screen.getByText(/Bambu/)).toBeInTheDocument();
+    expect(screen.getByText(/PLA/)).toBeInTheDocument();
+  });
+
+  it('shows spool color circle with correct hex color', () => {
+    const { container } = render(<SpoolInfoCard spool={mockSpool} scaleWeight={null} />);
+
+    // SpoolIcon renders an SVG circle with fill=colorHex
+    const circle = container.querySelector('circle[fill="#E8F5E9"]');
+    expect(circle).toBeInTheDocument();
+  });
+
+  it('shows remaining weight and fill percentage', () => {
+    // scaleWeight=900g, core=250g → remaining = 900-250 = 650g
+    // fillPercent = round(650/1000 * 100) = 65%
+    render(<SpoolInfoCard spool={mockSpool} scaleWeight={900} />);
+
+    expect(screen.getByText('650g')).toBeInTheDocument();
+    expect(screen.getByText('65%')).toBeInTheDocument();
+  });
+
+  it('calls onAssignToAms when "Assign to AMS" button clicked', () => {
+    const onAssign = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onAssignToAms={onAssign} />
+    );
+
+    fireEvent.click(screen.getByText('Assign to AMS'));
+    expect(onAssign).toHaveBeenCalledTimes(1);
+  });
+
+  it('calls onSyncWeight when sync button clicked', async () => {
+    const onSync = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={800} onSyncWeight={onSync} />
+    );
+
+    fireEvent.click(screen.getByText('Sync Weight'));
+
+    await waitFor(() => {
+      expect(mockUpdateSpoolWeight).toHaveBeenCalledWith(42, 800);
+    });
+  });
+
+  it('calls onClose when close button clicked', () => {
+    const onClose = vi.fn();
+    render(
+      <SpoolInfoCard spool={mockSpool} scaleWeight={null} onClose={onClose} />
+    );
+
+    fireEvent.click(screen.getByText('Close'));
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+});
+
+describe('UnknownTagCard', () => {
+  it('renders tag UID', () => {
+    render(<UnknownTagCard tagUid="DEADBEEF12345678" scaleWeight={null} />);
+
+    expect(screen.getByText('DEADBEEF12345678')).toBeInTheDocument();
+    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
+  });
+
+  it('shows "Add to Inventory" button', () => {
+    const onAdd = vi.fn();
+    render(
+      <UnknownTagCard tagUid="DEADBEEF" scaleWeight={null} onAddToInventory={onAdd} />
+    );
+
+    const btn = screen.getByText('Add to Inventory');
+    expect(btn).toBeInTheDocument();
+    fireEvent.click(btn);
+    expect(onAdd).toHaveBeenCalledTimes(1);
+  });
+});

+ 110 - 0
frontend/src/__tests__/components/TagDetectedModal.test.tsx

@@ -0,0 +1,110 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { TagDetectedModal } from '../../components/spoolbuddy/TagDetectedModal';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+
+const mockUpdateSpoolWeight = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    updateSpoolWeight: (...args: unknown[]) => mockUpdateSpoolWeight(...args),
+  },
+}));
+
+const mockSpool: MatchedSpool = {
+  id: 7,
+  tag_uid: 'AA11BB22CC33DD44',
+  material: 'PETG',
+  subtype: 'HF',
+  color_name: 'Orange',
+  rgba: 'FF6600FF',
+  brand: 'Overture',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 100,
+};
+
+const defaultProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  spool: mockSpool,
+  tagUid: 'AA11BB22CC33DD44',
+  scaleWeight: 950.0,
+  weightStable: true,
+  onSyncWeight: vi.fn(),
+  onAssignToAms: vi.fn(),
+  onLinkSpool: vi.fn(),
+  onAddToInventory: vi.fn(),
+};
+
+describe('TagDetectedModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockUpdateSpoolWeight.mockResolvedValue({ status: 'ok', weight_used: 300 });
+  });
+
+  it('does not render when isOpen=false', () => {
+    render(<TagDetectedModal {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText('Spool Detected')).not.toBeInTheDocument();
+  });
+
+  it('renders known spool view when spool provided', () => {
+    render(<TagDetectedModal {...defaultProps} />);
+
+    expect(screen.getByText('Spool Detected')).toBeInTheDocument();
+    expect(screen.getByText('Orange')).toBeInTheDocument();
+    expect(screen.getByText(/Overture/)).toBeInTheDocument();
+    expect(screen.getByText(/PETG/)).toBeInTheDocument();
+  });
+
+  it('renders unknown tag view when spool is null', () => {
+    render(
+      <TagDetectedModal
+        {...defaultProps}
+        spool={null}
+        tagUid="DEADBEEF11223344"
+      />
+    );
+
+    expect(screen.getByText('New Tag Detected')).toBeInTheDocument();
+    expect(screen.getByText('DEADBEEF11223344')).toBeInTheDocument();
+  });
+
+  it('closes on Escape key', () => {
+    const onClose = vi.fn();
+    render(<TagDetectedModal {...defaultProps} onClose={onClose} />);
+
+    fireEvent.keyDown(document, { key: 'Escape' });
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('shows weight from scale', () => {
+    // scaleWeight=950g, core=250g → remaining = 950-250 = 700g
+    render(<TagDetectedModal {...defaultProps} scaleWeight={950} />);
+
+    expect(screen.getByText('700g')).toBeInTheDocument();
+  });
+
+  it('shows action buttons (Assign to AMS, Sync Weight)', () => {
+    const onAssign = vi.fn();
+    const onSync = vi.fn();
+    render(
+      <TagDetectedModal
+        {...defaultProps}
+        onAssignToAms={onAssign}
+        onSyncWeight={onSync}
+      />
+    );
+
+    expect(screen.getByText('Assign to AMS')).toBeInTheDocument();
+    expect(screen.getByText('Sync Weight')).toBeInTheDocument();
+
+    fireEvent.click(screen.getByText('Assign to AMS'));
+    expect(onAssign).toHaveBeenCalledTimes(1);
+  });
+});

+ 80 - 0
frontend/src/__tests__/components/WeightDisplay.test.tsx

@@ -0,0 +1,80 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
+
+const mockTare = vi.fn();
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    tare: (...args: unknown[]) => mockTare(...args),
+  },
+}));
+
+const defaultProps = {
+  weight: 823.4,
+  weightStable: true,
+  deviceOnline: true,
+  deviceId: 'sb-0001',
+};
+
+describe('WeightDisplay', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mockTare.mockResolvedValue({ status: 'ok' });
+  });
+
+  it('renders weight value with 1 decimal place', () => {
+    render(<WeightDisplay {...defaultProps} weight={823.456} />);
+    expect(screen.getByText('823.5')).toBeInTheDocument();
+  });
+
+  it('shows green dot when stable and online', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} weightStable={true} deviceOnline={true} />
+    );
+    const dot = container.querySelector('.bg-green-500');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('Stable')).toBeInTheDocument();
+  });
+
+  it('shows amber dot when unstable', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} weightStable={false} deviceOnline={true} />
+    );
+    const dot = container.querySelector('.bg-amber-500');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('Measuring...')).toBeInTheDocument();
+  });
+
+  it('shows gray dot when offline', () => {
+    const { container } = render(
+      <WeightDisplay {...defaultProps} deviceOnline={false} />
+    );
+    const dot = container.querySelector('.bg-zinc-600');
+    expect(dot).toBeInTheDocument();
+    expect(screen.getByText('No reading')).toBeInTheDocument();
+  });
+
+  it('tare button calls spoolbuddyApi.tare(deviceId)', async () => {
+    render(<WeightDisplay {...defaultProps} />);
+
+    const tareButton = screen.getByText('Tare');
+    fireEvent.click(tareButton);
+
+    await waitFor(() => {
+      expect(mockTare).toHaveBeenCalledWith('sb-0001');
+    });
+  });
+
+  it('tare button is disabled when no deviceId', () => {
+    render(<WeightDisplay {...defaultProps} deviceId={null} />);
+
+    const tareButton = screen.getByText('Tare');
+    expect(tareButton).toBeDisabled();
+  });
+});

+ 5 - 1
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -565,8 +565,12 @@ export function ConfigureAmsSlotModal({
     } else if (cloudSettings?.filament) {
     } else if (cloudSettings?.filament) {
       const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
       const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
       presetName = cp?.name || null;
       presetName = cp?.name || null;
+    } else {
+      // No cloud settings available
+    }
+    if (!presetName) {
+      return null;
     }
     }
-    if (!presetName) return null;
 
 
     // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
     // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
     let nameWithoutSuffix = presetName.replace(/@.+$/, '').trim();
     let nameWithoutSuffix = presetName.replace(/@.+$/, '').trim();

+ 23 - 1
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
 import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { useQuery, useMutation } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
 import { X, Loader2, CheckCircle, XCircle, Layers } from 'lucide-react';
 import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
 import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../../api/client';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
@@ -29,6 +29,7 @@ interface AssignToAmsModalProps {
 
 
 export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
 export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
   const [statusMessage, setStatusMessage] = useState<string | null>(null);
   const [statusMessage, setStatusMessage] = useState<string | null>(null);
   const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
   const [statusType, setStatusType] = useState<'info' | 'success' | 'error' | null>(null);
 
 
@@ -98,10 +99,31 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignTo
         ams_id: amsId,
         ams_id: amsId,
         tray_id: trayId,
         tray_id: trayId,
       });
       });
+
+      // Save slot preset mapping so ConfigureAmsSlotModal can show the preset
+      // (same as ConfigureAmsSlotModal does after configuring a slot)
+      if (spool.slicer_filament) {
+        const base = spool.slicer_filament.includes('_')
+          ? spool.slicer_filament.split('_')[0]
+          : spool.slicer_filament;
+        // Convert filament_id (GFL05) → setting_id (GFSL05); user presets (P*) pass through
+        const presetId = base.startsWith('GF') && !base.startsWith('GFS')
+          ? 'GFS' + base.slice(2)
+          : base;
+        const presetName = spool.subtype
+          ? `${spool.material} ${spool.subtype}`
+          : spool.material;
+        try {
+          await api.saveSlotPreset(printerId, amsId, trayId, presetId, presetName, 'cloud');
+        } catch (e) {
+          console.warn('Failed to save slot preset mapping:', e);
+        }
+      }
     },
     },
     onSuccess: () => {
     onSuccess: () => {
       setStatusType('success');
       setStatusType('success');
       setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
       setStatusMessage(t('spoolbuddy.modal.assignSuccess', 'Assigned!'));
+      queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
       setTimeout(() => onClose(), 1500);
       setTimeout(() => onClose(), 1500);
     },
     },
     onError: (err) => {
     onError: (err) => {

+ 5 - 5
frontend/src/pages/PrintersPage.tsx

@@ -2407,7 +2407,7 @@ function PrinterCard({
             {/* Compact: Simple status bar */}
             {/* Compact: Simple status bar */}
             {viewMode === 'compact' ? (
             {viewMode === 'compact' ? (
               <div className="mt-2">
               <div className="mt-2">
-                {status.state === 'RUNNING' ? (
+                {(status.state === 'RUNNING' || status.state === 'PAUSE') ? (
                   <div className="flex items-center gap-2">
                   <div className="flex items-center gap-2">
                     <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5">
                     <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5">
                       <div
                       <div
@@ -2456,14 +2456,14 @@ function PrinterCard({
                   <div className="flex gap-3">
                   <div className="flex gap-3">
                     {/* Cover Image */}
                     {/* Cover Image */}
                     <CoverImage
                     <CoverImage
-                      url={status.state === 'RUNNING' ? status.cover_url : null}
-                      printName={status.state === 'RUNNING' ? (status.subtask_name || status.current_print || undefined) : undefined}
+                      url={(status.state === 'RUNNING' || status.state === 'PAUSE') ? status.cover_url : null}
+                      printName={(status.state === 'RUNNING' || status.state === 'PAUSE') ? (status.subtask_name || status.current_print || undefined) : undefined}
                     />
                     />
                     {/* Print Info */}
                     {/* Print Info */}
                     <div className="flex-1 min-w-0">
                     <div className="flex-1 min-w-0">
-                      {status.current_print && status.state === 'RUNNING' ? (
+                      {status.current_print && (status.state === 'RUNNING' || status.state === 'PAUSE') ? (
                         <>
                         <>
-                          <p className="text-sm text-bambu-gray mb-1">{status.stg_cur_name || 'Printing'}</p>
+                          <p className="text-sm text-bambu-gray mb-1">{getStatusDisplay(status.state, status.stg_cur_name)}</p>
                           <p className="text-white text-sm mb-2 truncate">
                           <p className="text-white text-sm mb-2 truncate">
                             {status.subtask_name || status.current_print}
                             {status.subtask_name || status.current_print}
                           </p>
                           </p>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-B6dleTdN.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C5QNmsOn.js"></script>
+    <script type="module" crossorigin src="/assets/index-B6dleTdN.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Dgxdt1Fd.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Dgxdt1Fd.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов