Browse Source

Add SpoolBuddy integration as optional filament management add-on

SpoolBuddy turns a Raspberry Pi 4B with a PN5180 NFC reader and
NAU7802 scale into a filament management station that integrates
with Bambuddy via REST API and WebSocket.

Backend: SpoolBuddyDevice model, 10 REST endpoints (/spoolbuddy/*),
6 WebSocket broadcast types, background offline-detection watchdog.

RPi daemon: asyncio service with concurrent NFC polling (300ms,
MIFARE Classic + Bambu HKDF key derivation), scale reading (10 SPS,
5-sample moving average, stability detection), and 10s heartbeat
with exponential backoff reconnect.

Frontend: kiosk-optimized 1024x600 UI at /spoolbuddy with Dashboard
(live weight + NFC spool detection), AMS Overview, Inventory,
Printers, and Settings pages. useSpoolBuddyState reducer hook
driven by WebSocket CustomEvents.

i18n: all 6 locales (en, de, fr, it, ja, pt-BR).
maziggy 3 months ago
parent
commit
aeca15b585
42 changed files with 2820 additions and 2 deletions
  1. 3 0
      CHANGELOG.md
  2. 388 0
      backend/app/api/routes/spoolbuddy.py
  3. 1 0
      backend/app/core/database.py
  4. 40 0
      backend/app/main.py
  5. 2 0
      backend/app/models/__init__.py
  6. 29 0
      backend/app/models/spoolbuddy_device.py
  7. 102 0
      backend/app/schemas/spoolbuddy.py
  8. 15 0
      frontend/src/App.tsx
  9. 32 0
      frontend/src/api/client.ts
  10. 41 0
      frontend/src/components/spoolbuddy/AmsSlotCard.tsx
  11. 37 0
      frontend/src/components/spoolbuddy/QuickActionGrid.tsx
  12. 30 0
      frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx
  13. 56 0
      frontend/src/components/spoolbuddy/SpoolBuddyNav.tsx
  14. 42 0
      frontend/src/components/spoolbuddy/SpoolBuddyStatusBar.tsx
  15. 68 0
      frontend/src/components/spoolbuddy/SpoolInfoCard.tsx
  16. 47 0
      frontend/src/components/spoolbuddy/UnknownTagCard.tsx
  17. 62 0
      frontend/src/components/spoolbuddy/WeightDisplay.tsx
  18. 175 0
      frontend/src/hooks/useSpoolBuddyState.ts
  19. 33 0
      frontend/src/hooks/useWebSocket.ts
  20. 65 0
      frontend/src/i18n/locales/de.ts
  21. 65 0
      frontend/src/i18n/locales/en.ts
  22. 65 0
      frontend/src/i18n/locales/fr.ts
  23. 65 0
      frontend/src/i18n/locales/it.ts
  24. 65 0
      frontend/src/i18n/locales/ja.ts
  25. 65 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 88 0
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  27. 115 0
      frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
  28. 105 0
      frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx
  29. 74 0
      frontend/src/pages/spoolbuddy/SpoolBuddyPrintersPage.tsx
  30. 165 0
      frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx
  31. 0 0
      spoolbuddy/daemon/__init__.py
  32. 147 0
      spoolbuddy/daemon/api_client.py
  33. 81 0
      spoolbuddy/daemon/config.py
  34. 168 0
      spoolbuddy/daemon/main.py
  35. 131 0
      spoolbuddy/daemon/nfc_reader.py
  36. 93 0
      spoolbuddy/daemon/scale_reader.py
  37. 17 0
      spoolbuddy/daemon/systemd/spoolbuddy.service
  38. 41 0
      spoolbuddy/daemon/tag_parser.py
  39. 0 0
      static/assets/index-B00gz1lY.css
  40. 0 0
      static/assets/index-DlQCzTdY.css
  41. 0 0
      static/assets/index-DmSskpar.js
  42. 2 2
      static/index.html

+ 3 - 0
CHANGELOG.md

@@ -9,6 +9,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **File Manager Rename Doesn't Update Displayed Name** ([#460](https://github.com/maziggy/bambuddy/issues/460)) — Renaming a file in the File Manager updated the `filename` field but not `file_metadata.print_name`, which the UI uses as the primary display name. Since `print_name` is extracted from inside the 3MF at upload time, it always took precedence over the renamed `filename`. The rename endpoint now also updates `print_name` in the file metadata when present.
 - **Finish Photo Not Captured When Archive Has No Source 3MF** ([#484](https://github.com/maziggy/bambuddy/issues/484)) — When a print completed but the 3MF source file wasn't downloaded from the printer (e.g. FTP download failure), the archive's `file_path` was null. The finish photo capture silently skipped because it derived the save directory from `file_path`. Now falls back to `archive/{id}/` so the photo is captured regardless.
 
+### New Features
+- **SpoolBuddy Integration** — SpoolBuddy is now an optional add-on module within Bambuddy for managing filament spools with NFC scanning and precision weight measurement. Hardware: Raspberry Pi 4B + 7" touchscreen (1024x600) + PN5180 NFC reader + NAU7802 scale. **RPi daemon** (`spoolbuddy/daemon/`): asyncio-based Python service with concurrent NFC polling (300ms, MIFARE Classic with Bambu HKDF-SHA256 key derivation), scale reading (10 SPS, 5-sample moving average, stability detection), and 10s heartbeat — posts events to the Bambuddy backend via REST API with exponential backoff reconnect and 100-event in-memory buffer on failure. **Backend**: new `SpoolBuddyDevice` model for device registration, 10 REST endpoints under `/spoolbuddy/*` (device register/heartbeat, NFC tag-scanned/tag-removed, scale reading/weight-update, calibration tare/set-factor/get), 6 new WebSocket broadcast message types (`spoolbuddy_weight`, `spoolbuddy_tag_matched`, `spoolbuddy_unknown_tag`, `spoolbuddy_tag_removed`, `spoolbuddy_online`, `spoolbuddy_offline`), and a background watchdog task that marks devices offline after 30s without heartbeat. **Frontend**: dedicated kiosk-optimized UI at `/spoolbuddy` routes designed for a fixed 1024x600 pixel display with 48px minimum touch targets — includes Dashboard (50/50 split with live weight display in 72px tabular-nums and state-dependent spool info/actions), AMS Overview (printer selector + slot grid), Inventory (touch-friendly 2-column card grid with search), Printers (status cards with live data), and Settings (scale calibration with live weight, NFC reader status, device info). `useSpoolBuddyState` hook manages a reducer-based state machine driven by WebSocket CustomEvents. Translations added for all 6 locales (en, de, fr, it, ja, pt-BR).
+
 ## [0.2.1b2] - 2026-02-21
 
 ### Fixed

+ 388 - 0
backend/app/api/routes/spoolbuddy.py

@@ -0,0 +1,388 @@
+"""SpoolBuddy device management API routes."""
+
+import logging
+from datetime import datetime, timedelta, timezone
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+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.core.websocket import ws_manager
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
+from backend.app.models.user import User
+from backend.app.schemas.spoolbuddy import (
+    CalibrationResponse,
+    DeviceRegisterRequest,
+    DeviceResponse,
+    HeartbeatRequest,
+    HeartbeatResponse,
+    ScaleReadingRequest,
+    SetCalibrationFactorRequest,
+    TagRemovedRequest,
+    TagScannedRequest,
+    UpdateSpoolWeightRequest,
+)
+from backend.app.services.spool_tag_matcher import get_spool_by_tag
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
+
+OFFLINE_THRESHOLD_SECONDS = 30
+
+
+def _is_online(device: SpoolBuddyDevice) -> bool:
+    if not device.last_seen:
+        return False
+    return (
+        datetime.now(timezone.utc) - device.last_seen.replace(tzinfo=timezone.utc)
+    ).total_seconds() < OFFLINE_THRESHOLD_SECONDS
+
+
+def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
+    return DeviceResponse(
+        id=device.id,
+        device_id=device.device_id,
+        hostname=device.hostname,
+        ip_address=device.ip_address,
+        firmware_version=device.firmware_version,
+        has_nfc=device.has_nfc,
+        has_scale=device.has_scale,
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+        last_seen=device.last_seen,
+        pending_command=device.pending_command,
+        nfc_ok=device.nfc_ok,
+        scale_ok=device.scale_ok,
+        uptime_s=device.uptime_s,
+        online=_is_online(device),
+        created_at=device.created_at,
+        updated_at=device.updated_at,
+    )
+
+
+# --- Device endpoints ---
+
+
+@router.post("/devices/register", response_model=DeviceResponse)
+async def register_device(
+    req: DeviceRegisterRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Register or re-register a SpoolBuddy device."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
+    device = result.scalar_one_or_none()
+
+    now = datetime.now(timezone.utc)
+    if device:
+        device.hostname = req.hostname
+        device.ip_address = req.ip_address
+        device.firmware_version = req.firmware_version
+        device.has_nfc = req.has_nfc
+        device.has_scale = req.has_scale
+        device.last_seen = now
+        logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
+    else:
+        device = SpoolBuddyDevice(
+            device_id=req.device_id,
+            hostname=req.hostname,
+            ip_address=req.ip_address,
+            firmware_version=req.firmware_version,
+            has_nfc=req.has_nfc,
+            has_scale=req.has_scale,
+            tare_offset=req.tare_offset,
+            calibration_factor=req.calibration_factor,
+            last_seen=now,
+        )
+        db.add(device)
+        logger.info("SpoolBuddy device registered: %s (%s)", req.device_id, req.hostname)
+
+    await db.commit()
+    await db.refresh(device)
+
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_online",
+            "device_id": device.device_id,
+            "hostname": device.hostname,
+        }
+    )
+
+    return _device_to_response(device)
+
+
+@router.get("/devices", response_model=list[DeviceResponse])
+async def list_devices(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List all registered SpoolBuddy devices."""
+    result = await db.execute(select(SpoolBuddyDevice).order_by(SpoolBuddyDevice.hostname))
+    devices = list(result.scalars().all())
+    return [_device_to_response(d) for d in devices]
+
+
+@router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
+async def device_heartbeat(
+    device_id: str,
+    req: HeartbeatRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Daemon heartbeat — updates status and returns pending commands."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    was_offline = not _is_online(device)
+    now = datetime.now(timezone.utc)
+
+    device.last_seen = now
+    device.nfc_ok = req.nfc_ok
+    device.scale_ok = req.scale_ok
+    device.uptime_s = req.uptime_s
+    if req.firmware_version:
+        device.firmware_version = req.firmware_version
+    if req.ip_address:
+        device.ip_address = req.ip_address
+
+    # Return and clear pending command
+    pending = device.pending_command
+    device.pending_command = None
+
+    await db.commit()
+
+    if was_offline:
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_online",
+                "device_id": device.device_id,
+                "hostname": device.hostname,
+            }
+        )
+
+    return HeartbeatResponse(
+        pending_command=pending,
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+    )
+
+
+# --- NFC endpoints ---
+
+
+@router.post("/nfc/tag-scanned")
+async def nfc_tag_scanned(
+    req: TagScannedRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """RPi reports NFC tag detected — lookup spool and broadcast."""
+    spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
+
+    if spool:
+        await ws_manager.broadcast(
+            {
+                "type": "spoolbuddy_tag_matched",
+                "device_id": req.device_id,
+                "tag_uid": req.tag_uid,
+                "spool": {
+                    "id": spool.id,
+                    "material": spool.material,
+                    "subtype": spool.subtype,
+                    "color_name": spool.color_name,
+                    "rgba": spool.rgba,
+                    "brand": spool.brand,
+                    "label_weight": spool.label_weight,
+                    "core_weight": spool.core_weight,
+                    "weight_used": spool.weight_used,
+                },
+            }
+        )
+        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: %s", req.tag_uid)
+
+    return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
+
+
+@router.post("/nfc/tag-removed")
+async def nfc_tag_removed(
+    req: TagRemovedRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """RPi reports NFC tag removed — broadcast event."""
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_tag_removed",
+            "device_id": req.device_id,
+            "tag_uid": req.tag_uid,
+        }
+    )
+    return {"status": "ok"}
+
+
+# --- Scale endpoints ---
+
+
+@router.post("/scale/reading")
+async def scale_reading(
+    req: ScaleReadingRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """RPi reports scale weight — broadcast to all clients."""
+    await ws_manager.broadcast(
+        {
+            "type": "spoolbuddy_weight",
+            "device_id": req.device_id,
+            "weight_grams": req.weight_grams,
+            "stable": req.stable,
+            "raw_adc": req.raw_adc,
+        }
+    )
+    return {"status": "ok"}
+
+
+@router.post("/scale/update-spool-weight")
+async def update_spool_weight(
+    req: UpdateSpoolWeightRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update spool's used weight from scale reading."""
+    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 not spool:
+        raise HTTPException(status_code=404, detail="Spool not found")
+
+    # net weight = total on scale minus empty spool core
+    net_filament = max(0, req.weight_grams - spool.core_weight)
+    spool.weight_used = max(0, spool.label_weight - net_filament)
+    await db.commit()
+
+    logger.info(
+        "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
+        spool.id,
+        req.weight_grams,
+        spool.weight_used,
+    )
+    return {"status": "ok", "weight_used": spool.weight_used}
+
+
+# --- Calibration endpoints ---
+
+
+@router.post("/devices/{device_id}/calibration/tare")
+async def tare_scale(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Set pending tare command for the device to pick up."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.pending_command = "tare"
+    await db.commit()
+    return {"status": "ok", "message": "Tare command queued"}
+
+
+@router.post("/devices/{device_id}/calibration/set-factor")
+async def set_calibration_factor(
+    device_id: str,
+    req: SetCalibrationFactorRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Calculate and store calibration factor from a known weight."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    raw_delta = req.raw_adc - device.tare_offset
+    if raw_delta == 0:
+        raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
+
+    device.calibration_factor = req.known_weight_grams / raw_delta
+    await db.commit()
+
+    logger.info(
+        "SpoolBuddy %s calibration factor set to %.6f (known=%.1fg, raw=%d, tare=%d)",
+        device_id,
+        device.calibration_factor,
+        req.known_weight_grams,
+        req.raw_adc,
+        device.tare_offset,
+    )
+    return CalibrationResponse(
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+    )
+
+
+@router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
+async def get_calibration(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get current calibration values for a device."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    return CalibrationResponse(
+        tare_offset=device.tare_offset,
+        calibration_factor=device.calibration_factor,
+    )
+
+
+# --- Background watchdog ---
+
+
+async def spoolbuddy_watchdog():
+    """Check for devices that have gone offline (no heartbeat for 30s).
+
+    Called periodically from the main app's background task loop.
+    """
+    from backend.app.core.database import async_session
+
+    async with async_session() as db:
+        result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.last_seen.isnot(None)))
+        devices = list(result.scalars().all())
+
+        threshold = datetime.now(timezone.utc) - timedelta(seconds=OFFLINE_THRESHOLD_SECONDS)
+        for device in devices:
+            last_seen = device.last_seen.replace(tzinfo=timezone.utc) if device.last_seen else None
+            if last_seen and last_seen < threshold:
+                # Only broadcast once — clear last_seen after marking offline
+                await ws_manager.broadcast(
+                    {
+                        "type": "spoolbuddy_offline",
+                        "device_id": device.device_id,
+                    }
+                )
+                device.last_seen = None
+                logger.info("SpoolBuddy device offline: %s", device.device_id)
+
+        await db.commit()

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

@@ -102,6 +102,7 @@ async def init_db():
         spool_catalog,
         spool_k_profile,
         spool_usage_history,
+        spoolbuddy_device,
         user,
         virtual_printer,
     )

+ 40 - 0
backend/app/main.py

@@ -39,6 +39,7 @@ from backend.app.api.routes import (
     projects,
     settings as settings_routes,
     smart_plugs,
+    spoolbuddy,
     spoolman,
     support,
     system,
@@ -3175,6 +3176,40 @@ def stop_runtime_tracking():
         logging.getLogger(__name__).info("Printer runtime tracking stopped")
 
 
+# SpoolBuddy device watchdog
+_spoolbuddy_watchdog_task: asyncio.Task | None = None
+SPOOLBUDDY_WATCHDOG_INTERVAL = 15
+
+
+async def _spoolbuddy_watchdog_loop():
+    """Periodic check for SpoolBuddy devices that have gone offline."""
+    from backend.app.api.routes.spoolbuddy import spoolbuddy_watchdog
+
+    while True:
+        try:
+            await spoolbuddy_watchdog()
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logging.getLogger(__name__).warning("SpoolBuddy watchdog failed: %s", e)
+        await asyncio.sleep(SPOOLBUDDY_WATCHDOG_INTERVAL)
+
+
+def start_spoolbuddy_watchdog():
+    global _spoolbuddy_watchdog_task
+    if _spoolbuddy_watchdog_task is None:
+        _spoolbuddy_watchdog_task = asyncio.create_task(_spoolbuddy_watchdog_loop())
+        logging.getLogger(__name__).info("SpoolBuddy watchdog started")
+
+
+def stop_spoolbuddy_watchdog():
+    global _spoolbuddy_watchdog_task
+    if _spoolbuddy_watchdog_task:
+        _spoolbuddy_watchdog_task.cancel()
+        _spoolbuddy_watchdog_task = None
+        logging.getLogger(__name__).info("SpoolBuddy watchdog stopped")
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
@@ -3281,6 +3316,9 @@ async def lifespan(app: FastAPI):
     # Start printer runtime tracking
     start_runtime_tracking()
 
+    # Start SpoolBuddy device watchdog
+    start_spoolbuddy_watchdog()
+
     # Initialize virtual printer manager and sync from DB
     from backend.app.services.virtual_printer import virtual_printer_manager
 
@@ -3301,6 +3339,7 @@ async def lifespan(app: FastAPI):
     github_backup_service.stop_scheduler()
     stop_ams_history_recording()
     stop_runtime_tracking()
+    stop_spoolbuddy_watchdog()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 
@@ -3505,6 +3544,7 @@ app.include_router(firmware.router, prefix=app_settings.api_prefix)
 app.include_router(github_backup.router, prefix=app_settings.api_prefix)
 app.include_router(metrics.router, prefix=app_settings.api_prefix)
 app.include_router(virtual_printers.router, prefix=app_settings.api_prefix)
+app.include_router(spoolbuddy.router, prefix=app_settings.api_prefix)
 
 
 # Serve static files (React build)

+ 2 - 0
backend/app/models/__init__.py

@@ -22,6 +22,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.user import User
 
 __all__ = [
@@ -55,4 +56,5 @@ __all__ = [
     "SpoolCatalogEntry",
     "SpoolUsageHistory",
     "ColorCatalogEntry",
+    "SpoolBuddyDevice",
 ]

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

@@ -0,0 +1,29 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SpoolBuddyDevice(Base):
+    """SpoolBuddy device registration for RPi-based filament management stations."""
+
+    __tablename__ = "spoolbuddy_devices"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    device_id: Mapped[str] = mapped_column(String(50), unique=True, index=True)
+    hostname: Mapped[str] = mapped_column(String(100))
+    ip_address: Mapped[str] = mapped_column(String(45))
+    firmware_version: Mapped[str | None] = mapped_column(String(20))
+    has_nfc: Mapped[bool] = mapped_column(Boolean, default=True)
+    has_scale: Mapped[bool] = mapped_column(Boolean, default=True)
+    tare_offset: Mapped[int] = mapped_column(Integer, default=0)
+    calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
+    last_seen: Mapped[datetime | None] = mapped_column(DateTime)
+    pending_command: Mapped[str | None] = mapped_column(String(50))
+    nfc_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)
+    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())

+ 102 - 0
backend/app/schemas/spoolbuddy.py

@@ -0,0 +1,102 @@
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+# --- Device schemas ---
+
+
+class DeviceRegisterRequest(BaseModel):
+    device_id: str = Field(..., min_length=1, max_length=50)
+    hostname: str = Field(..., min_length=1, max_length=100)
+    ip_address: str = Field(..., min_length=1, max_length=45)
+    firmware_version: str | None = None
+    has_nfc: bool = True
+    has_scale: bool = True
+    tare_offset: int = 0
+    calibration_factor: float = 1.0
+
+
+class DeviceResponse(BaseModel):
+    id: int
+    device_id: str
+    hostname: str
+    ip_address: str
+    firmware_version: str | None = None
+    has_nfc: bool
+    has_scale: bool
+    tare_offset: int
+    calibration_factor: float
+    last_seen: datetime | None = None
+    pending_command: str | None = None
+    nfc_ok: bool
+    scale_ok: bool
+    uptime_s: int
+    online: bool = False
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class HeartbeatRequest(BaseModel):
+    nfc_ok: bool = False
+    scale_ok: bool = False
+    uptime_s: int = 0
+    firmware_version: str | None = None
+    ip_address: str | None = None
+
+
+class HeartbeatResponse(BaseModel):
+    pending_command: str | None = None
+    tare_offset: int
+    calibration_factor: float
+
+
+# --- NFC schemas ---
+
+
+class TagScannedRequest(BaseModel):
+    device_id: str
+    tag_uid: str
+    tray_uuid: str | None = None
+    sak: int | None = None
+    tag_type: str | None = None
+    raw_blocks: dict | None = None
+
+
+class TagRemovedRequest(BaseModel):
+    device_id: str
+    tag_uid: str
+
+
+# --- Scale schemas ---
+
+
+class ScaleReadingRequest(BaseModel):
+    device_id: str
+    weight_grams: float
+    stable: bool = False
+    raw_adc: int | None = None
+
+
+class UpdateSpoolWeightRequest(BaseModel):
+    spool_id: int
+    weight_grams: float
+
+
+# --- Calibration schemas ---
+
+
+class TareRequest(BaseModel):
+    pass
+
+
+class SetCalibrationFactorRequest(BaseModel):
+    known_weight_grams: float = Field(..., gt=0)
+    raw_adc: int
+
+
+class CalibrationResponse(BaseModel):
+    tare_offset: int
+    calibration_factor: float

+ 15 - 0
frontend/src/App.tsx

@@ -19,6 +19,12 @@ import InventoryPage from './pages/InventoryPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
+import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
+import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
+import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
+import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
+import { SpoolBuddyPrintersPage } from './pages/spoolbuddy/SpoolBuddyPrintersPage';
+import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -114,6 +120,15 @@ function App() {
                 {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
                 <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
 
+                {/* SpoolBuddy — standalone kiosk-optimized UI with its own layout */}
+                <Route element={<ProtectedRoute><WebSocketProvider><SpoolBuddyLayout /></WebSocketProvider></ProtectedRoute>}>
+                  <Route path="spoolbuddy" element={<SpoolBuddyDashboard />} />
+                  <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
+                  <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
+                  <Route path="spoolbuddy/printers" element={<SpoolBuddyPrintersPage />} />
+                  <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
+                </Route>
+
                 {/* Main app with WebSocket for real-time updates */}
                 <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />

+ 32 - 0
frontend/src/api/client.ts

@@ -4798,3 +4798,35 @@ export const supportApi = {
   clearLogs: () =>
     request<{ message: string }>('/support/logs', { method: 'DELETE' }),
 };
+
+export const spoolBuddyApi = {
+  getDevices: () =>
+    request<unknown[]>('/spoolbuddy/devices'),
+
+  registerDevice: (data: Record<string, unknown>) =>
+    request<unknown>('/spoolbuddy/devices/register', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
+  tare: (deviceId: string) =>
+    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/calibration/tare`, {
+      method: 'POST',
+      body: JSON.stringify({}),
+    }),
+
+  setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number) =>
+    request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration/set-factor`, {
+      method: 'POST',
+      body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc }),
+    }),
+
+  getCalibration: (deviceId: string) =>
+    request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration`),
+
+  updateSpoolWeight: (spoolId: number, weightGrams: number) =>
+    request<{ status: string; weight_used: number }>('/spoolbuddy/scale/update-spool-weight', {
+      method: 'POST',
+      body: JSON.stringify({ spool_id: spoolId, weight_grams: weightGrams }),
+    }),
+};

+ 41 - 0
frontend/src/components/spoolbuddy/AmsSlotCard.tsx

@@ -0,0 +1,41 @@
+interface AmsSlotCardProps {
+  material: string | null;
+  colorHex: string | null;
+  colorName: string | null;
+  remaining: number | null;
+  isEmpty: boolean;
+  onClick: () => void;
+}
+
+export function AmsSlotCard({ material, colorHex, remaining, isEmpty, onClick }: AmsSlotCardProps) {
+  const color = colorHex ? `#${colorHex.substring(0, 6)}` : '#808080';
+
+  return (
+    <button
+      onClick={onClick}
+      className="w-[120px] h-[120px] rounded-xl border border-bambu-dark-tertiary bg-bg-secondary flex flex-col items-center justify-center gap-2 active:bg-bambu-dark-tertiary transition-colors"
+    >
+      {isEmpty ? (
+        <span className="text-text-muted text-[13px]">Empty</span>
+      ) : (
+        <>
+          <div
+            className="w-[48px] h-[48px] rounded-full border-2 border-bambu-dark-tertiary"
+            style={{ backgroundColor: color }}
+          />
+          <span className="text-text-primary text-[13px] font-medium truncate max-w-[100px]">
+            {material || '---'}
+          </span>
+          {remaining !== null && remaining >= 0 && (
+            <div className="w-[80px] h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+              <div
+                className="h-full rounded-full bg-bambu-green"
+                style={{ width: `${Math.min(100, remaining)}%` }}
+              />
+            </div>
+          )}
+        </>
+      )}
+    </button>
+  );
+}

+ 37 - 0
frontend/src/components/spoolbuddy/QuickActionGrid.tsx

@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next';
+import { Button } from '../Button';
+import { Scale, Edit, Cpu, History } from 'lucide-react';
+
+interface QuickActionGridProps {
+  onUpdateWeight: () => void;
+  onEditSpool: () => void;
+  onAssignAms: () => void;
+  onViewHistory: () => void;
+}
+
+export function QuickActionGrid({ onUpdateWeight, onEditSpool, onAssignAms, onViewHistory }: QuickActionGridProps) {
+  const { t } = useTranslation();
+
+  const actions = [
+    { icon: Cpu, label: t('spoolbuddy.actions.assignAms'), onClick: onAssignAms, variant: 'primary' as const },
+    { icon: Scale, label: t('spoolbuddy.actions.updateWeight'), onClick: onUpdateWeight, variant: 'secondary' as const },
+    { icon: Edit, label: t('spoolbuddy.actions.editSpool'), onClick: onEditSpool, variant: 'secondary' as const },
+    { icon: History, label: t('spoolbuddy.actions.viewHistory'), onClick: onViewHistory, variant: 'secondary' as const },
+  ];
+
+  return (
+    <div className="grid grid-cols-2 gap-3">
+      {actions.map(({ icon: Icon, label, onClick, variant }) => (
+        <Button
+          key={label}
+          variant={variant}
+          className="h-[64px] text-[14px] flex-col gap-1"
+          onClick={onClick}
+        >
+          <Icon size={20} />
+          <span>{label}</span>
+        </Button>
+      ))}
+    </div>
+  );
+}

+ 30 - 0
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -0,0 +1,30 @@
+import { Outlet, useSearchParams } from 'react-router-dom';
+import { SpoolBuddyNav } from './SpoolBuddyNav';
+import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
+import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+
+export function SpoolBuddyLayout() {
+  const [searchParams] = useSearchParams();
+  const isKiosk = searchParams.get('kiosk') === '1' || window.innerHeight <= 600;
+  const state = useSpoolBuddyState();
+
+  return (
+    <div
+      className="w-[1024px] h-[600px] mx-auto flex flex-col bg-bg-primary overflow-hidden"
+      style={{ touchAction: 'manipulation' }}
+    >
+      <SpoolBuddyNav isKiosk={isKiosk} />
+
+      <main className="flex-1 overflow-hidden">
+        <Outlet context={state} />
+      </main>
+
+      <SpoolBuddyStatusBar
+        weightGrams={state.weight?.weight_grams ?? null}
+        stable={state.weight?.stable ?? false}
+        nfcOk={state.deviceOnline}
+        deviceOnline={state.deviceOnline}
+      />
+    </div>
+  );
+}

+ 56 - 0
frontend/src/components/spoolbuddy/SpoolBuddyNav.tsx

@@ -0,0 +1,56 @@
+import { NavLink } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { Scale, Cpu, Package, Printer, Settings, ArrowLeft } from 'lucide-react';
+
+const navItems = [
+  { to: '/spoolbuddy', icon: Scale, labelKey: 'spoolbuddy.nav.dashboard', end: true },
+  { to: '/spoolbuddy/ams', icon: Cpu, labelKey: 'spoolbuddy.nav.ams' },
+  { to: '/spoolbuddy/inventory', icon: Package, labelKey: 'spoolbuddy.nav.inventory' },
+  { to: '/spoolbuddy/printers', icon: Printer, labelKey: 'spoolbuddy.nav.printers' },
+  { to: '/spoolbuddy/settings', icon: Settings, labelKey: 'spoolbuddy.nav.settings' },
+];
+
+interface SpoolBuddyNavProps {
+  isKiosk: boolean;
+}
+
+export function SpoolBuddyNav({ isKiosk }: SpoolBuddyNavProps) {
+  const { t } = useTranslation();
+
+  return (
+    <nav className="h-[48px] bg-bg-secondary border-b border-bambu-dark-tertiary flex items-center px-2 gap-1">
+      {!isKiosk && (
+        <NavLink
+          to="/"
+          className="flex items-center gap-1 px-3 h-[40px] rounded-lg text-text-secondary hover:text-white hover:bg-bambu-dark-tertiary text-[13px]"
+        >
+          <ArrowLeft size={16} />
+        </NavLink>
+      )}
+
+      <div className="flex items-center gap-1 px-2">
+        <span className="text-bambu-green font-bold text-[15px]">SpoolBuddy</span>
+      </div>
+
+      <div className="flex items-center gap-1 flex-1">
+        {navItems.map(({ to, icon: Icon, labelKey, end }) => (
+          <NavLink
+            key={to}
+            to={to}
+            end={end}
+            className={({ isActive }) =>
+              `flex items-center gap-1.5 px-4 h-[40px] rounded-lg text-[13px] font-medium transition-colors ${
+                isActive
+                  ? 'bg-bambu-green text-white'
+                  : 'text-text-secondary hover:text-white hover:bg-bambu-dark-tertiary'
+              }`
+            }
+          >
+            <Icon size={16} />
+            <span>{t(labelKey)}</span>
+          </NavLink>
+        ))}
+      </div>
+    </nav>
+  );
+}

+ 42 - 0
frontend/src/components/spoolbuddy/SpoolBuddyStatusBar.tsx

@@ -0,0 +1,42 @@
+import { useTranslation } from 'react-i18next';
+import { Scale, Nfc } from 'lucide-react';
+
+interface SpoolBuddyStatusBarProps {
+  weightGrams: number | null;
+  stable: boolean;
+  nfcOk: boolean;
+  deviceOnline: boolean;
+}
+
+export function SpoolBuddyStatusBar({ weightGrams, stable, nfcOk, deviceOnline }: SpoolBuddyStatusBarProps) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="h-[40px] bg-bg-secondary border-t border-bambu-dark-tertiary flex items-center px-4 text-[13px]">
+      <div className="flex items-center gap-4 flex-1">
+        <div className="flex items-center gap-2">
+          <Scale size={14} className="text-text-secondary" />
+          <span className="text-text-primary font-mono">
+            {weightGrams !== null ? `${weightGrams.toFixed(1)}g` : '---'}
+          </span>
+          {weightGrams !== null && (
+            <span className={`w-2 h-2 rounded-full ${stable ? 'bg-green-500' : 'bg-yellow-500 animate-pulse'}`} />
+          )}
+        </div>
+
+        <div className="flex items-center gap-2">
+          <Nfc size={14} className="text-text-secondary" />
+          <span className={nfcOk ? 'text-green-500' : 'text-text-muted'}>
+            {nfcOk ? t('spoolbuddy.status.nfcReady') : t('spoolbuddy.status.nfcOff')}
+          </span>
+        </div>
+
+        {!deviceOnline && (
+          <span className="text-red-400 text-[12px]">{t('spoolbuddy.status.offline')}</span>
+        )}
+      </div>
+
+      <span className="text-text-muted text-[12px]">SpoolBuddy</span>
+    </div>
+  );
+}

+ 68 - 0
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -0,0 +1,68 @@
+import { useTranslation } from 'react-i18next';
+
+interface SpoolInfo {
+  id: number;
+  material: string;
+  subtype: string | null;
+  color_name: string | null;
+  rgba: string | null;
+  brand: string | null;
+  label_weight: number;
+  core_weight: number;
+  weight_used: number;
+}
+
+interface SpoolInfoCardProps {
+  spool: SpoolInfo;
+}
+
+export function SpoolInfoCard({ spool }: SpoolInfoCardProps) {
+  const { t } = useTranslation();
+
+  const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+  const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;
+
+  // Convert RRGGBBAA to CSS color
+  const color = spool.rgba
+    ? `#${spool.rgba.substring(0, 6)}`
+    : '#808080';
+
+  const materialLabel = spool.subtype
+    ? `${spool.material} ${spool.subtype}`
+    : spool.material;
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center gap-4">
+        <div
+          className="w-[48px] h-[48px] rounded-full border-2 border-bambu-dark-tertiary flex-shrink-0"
+          style={{ backgroundColor: color }}
+        />
+        <div className="flex-1 min-w-0">
+          <h3 className="text-[18px] font-semibold text-text-primary truncate">
+            {materialLabel}
+          </h3>
+          <p className="text-[14px] text-text-secondary truncate">
+            {[spool.brand, spool.color_name].filter(Boolean).join(' - ')}
+          </p>
+        </div>
+      </div>
+
+      <div className="space-y-2">
+        <div className="flex justify-between text-[14px]">
+          <span className="text-text-secondary">{t('spoolbuddy.spool.remaining')}</span>
+          <span className="text-text-primary font-medium">{remaining}g ({pct}%)</span>
+        </div>
+        <div className="w-full h-3 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+          <div
+            className="h-full rounded-full transition-all duration-300"
+            style={{
+              width: `${pct}%`,
+              backgroundColor: pct > 20 ? 'var(--accent)' : pct > 5 ? '#f59e0b' : '#ef4444',
+            }}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 47 - 0
frontend/src/components/spoolbuddy/UnknownTagCard.tsx

@@ -0,0 +1,47 @@
+import { useTranslation } from 'react-i18next';
+import { Button } from '../Button';
+import { AlertTriangle } from 'lucide-react';
+
+interface UnknownTagCardProps {
+  tagUid: string;
+  sak?: number;
+  tagType?: string;
+  onLinkExisting: () => void;
+  onCreateNew: () => void;
+}
+
+export function UnknownTagCard({ tagUid, sak, tagType, onLinkExisting, onCreateNew }: UnknownTagCardProps) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="flex flex-col items-center justify-center h-full px-6">
+      <AlertTriangle size={48} className="text-yellow-500 mb-4" />
+      <h3 className="text-[24px] font-semibold text-text-primary mb-2">
+        {t('spoolbuddy.tag.unknownTitle')}
+      </h3>
+      <p className="text-[14px] text-text-secondary mb-1 font-mono">{tagUid}</p>
+      {tagType && (
+        <p className="text-[13px] text-text-muted mb-6">
+          {tagType}{sak !== undefined ? ` (SAK: 0x${sak.toString(16).toUpperCase().padStart(2, '0')})` : ''}
+        </p>
+      )}
+
+      <div className="w-full space-y-3 max-w-[320px]">
+        <Button
+          variant="primary"
+          className="w-full h-[64px] text-[16px]"
+          onClick={onLinkExisting}
+        >
+          {t('spoolbuddy.tag.linkExisting')}
+        </Button>
+        <Button
+          variant="secondary"
+          className="w-full h-[64px] text-[16px]"
+          onClick={onCreateNew}
+        >
+          {t('spoolbuddy.tag.createNew')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 62 - 0
frontend/src/components/spoolbuddy/WeightDisplay.tsx

@@ -0,0 +1,62 @@
+import { useTranslation } from 'react-i18next';
+import { Button } from '../Button';
+
+interface WeightDisplayProps {
+  weightGrams: number | null;
+  stable: boolean;
+  rawAdc: number | null;
+  onTare: () => void;
+  onCalibrate: () => void;
+}
+
+export function WeightDisplay({ weightGrams, stable, onTare, onCalibrate }: WeightDisplayProps) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="flex flex-col items-center justify-center h-full px-4">
+      <div className="flex-1 flex flex-col items-center justify-center">
+        <span
+          className="text-[72px] font-bold text-text-primary leading-none"
+          style={{ fontVariantNumeric: 'tabular-nums' }}
+        >
+          {weightGrams !== null ? weightGrams.toFixed(1) : '---'}
+        </span>
+        <span className="text-[24px] text-text-secondary mt-1">g</span>
+
+        <div className="flex items-center gap-2 mt-3">
+          <span className={`w-3 h-3 rounded-full ${
+            weightGrams === null
+              ? 'bg-bambu-gray'
+              : stable
+                ? 'bg-green-500'
+                : 'bg-yellow-500 animate-pulse'
+          }`} />
+          <span className="text-[16px] text-text-secondary">
+            {weightGrams === null
+              ? t('spoolbuddy.weight.noReading')
+              : stable
+                ? t('spoolbuddy.weight.stable')
+                : t('spoolbuddy.weight.measuring')}
+          </span>
+        </div>
+      </div>
+
+      <div className="w-full flex gap-3 pb-4">
+        <Button
+          variant="secondary"
+          className="flex-1 h-[64px] text-[16px]"
+          onClick={onTare}
+        >
+          {t('spoolbuddy.weight.tare')}
+        </Button>
+        <Button
+          variant="secondary"
+          className="flex-1 h-[64px] text-[16px]"
+          onClick={onCalibrate}
+        >
+          {t('spoolbuddy.weight.calibrate')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 175 - 0
frontend/src/hooks/useSpoolBuddyState.ts

@@ -0,0 +1,175 @@
+import { useCallback, useEffect, useReducer } from 'react';
+
+// --- Types ---
+
+interface SpoolInfo {
+  id: number;
+  material: string;
+  subtype: string | null;
+  color_name: string | null;
+  rgba: string | null;
+  brand: string | null;
+  label_weight: number;
+  core_weight: number;
+  weight_used: number;
+}
+
+interface WeightData {
+  weight_grams: number;
+  stable: boolean;
+  raw_adc: number | null;
+  device_id: string;
+}
+
+interface TagData {
+  tag_uid: string;
+  sak?: number;
+  tag_type?: string;
+  tray_uuid?: string;
+  device_id: string;
+}
+
+type DashboardView = 'idle' | 'tag_known' | 'tag_unknown';
+
+interface SpoolBuddyState {
+  view: DashboardView;
+  weight: WeightData | null;
+  tag: TagData | null;
+  spool: SpoolInfo | null;
+  deviceOnline: boolean;
+}
+
+type Action =
+  | { type: 'WEIGHT_UPDATE'; payload: WeightData }
+  | { type: 'TAG_MATCHED'; payload: { tag: TagData; spool: SpoolInfo } }
+  | { type: 'TAG_UNKNOWN'; payload: TagData }
+  | { type: 'TAG_REMOVED' }
+  | { type: 'DEVICE_ONLINE' }
+  | { type: 'DEVICE_OFFLINE' };
+
+// --- Reducer ---
+
+const initialState: SpoolBuddyState = {
+  view: 'idle',
+  weight: null,
+  tag: null,
+  spool: null,
+  deviceOnline: false,
+};
+
+function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
+  switch (action.type) {
+    case 'WEIGHT_UPDATE':
+      return { ...state, weight: action.payload };
+
+    case 'TAG_MATCHED':
+      return {
+        ...state,
+        view: 'tag_known',
+        tag: action.payload.tag,
+        spool: action.payload.spool,
+      };
+
+    case 'TAG_UNKNOWN':
+      return {
+        ...state,
+        view: 'tag_unknown',
+        tag: action.payload,
+        spool: null,
+      };
+
+    case 'TAG_REMOVED':
+      return {
+        ...state,
+        view: 'idle',
+        tag: null,
+        spool: null,
+      };
+
+    case 'DEVICE_ONLINE':
+      return { ...state, deviceOnline: true };
+
+    case 'DEVICE_OFFLINE':
+      return { ...state, deviceOnline: false, weight: null };
+
+    default:
+      return state;
+  }
+}
+
+// --- Hook ---
+
+export function useSpoolBuddyState() {
+  const [state, dispatch] = useReducer(reducer, initialState);
+
+  const handleWeight = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    dispatch({
+      type: 'WEIGHT_UPDATE',
+      payload: {
+        weight_grams: detail.weight_grams,
+        stable: detail.stable,
+        raw_adc: detail.raw_adc ?? null,
+        device_id: detail.device_id,
+      },
+    });
+  }, []);
+
+  const handleTagMatched = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    dispatch({
+      type: 'TAG_MATCHED',
+      payload: {
+        tag: {
+          tag_uid: detail.tag_uid,
+          device_id: detail.device_id,
+        },
+        spool: detail.spool,
+      },
+    });
+  }, []);
+
+  const handleTagUnknown = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    dispatch({
+      type: 'TAG_UNKNOWN',
+      payload: {
+        tag_uid: detail.tag_uid,
+        sak: detail.sak,
+        tag_type: detail.tag_type,
+        device_id: detail.device_id,
+      },
+    });
+  }, []);
+
+  const handleTagRemoved = useCallback(() => {
+    dispatch({ type: 'TAG_REMOVED' });
+  }, []);
+
+  const handleDeviceStatus = useCallback((e: Event) => {
+    const detail = (e as CustomEvent).detail;
+    if (detail.type === 'spoolbuddy_online') {
+      dispatch({ type: 'DEVICE_ONLINE' });
+    } else {
+      dispatch({ type: 'DEVICE_OFFLINE' });
+    }
+  }, []);
+
+  useEffect(() => {
+    window.addEventListener('spoolbuddy-weight', handleWeight);
+    window.addEventListener('spoolbuddy-tag-matched', handleTagMatched);
+    window.addEventListener('spoolbuddy-unknown-tag', handleTagUnknown);
+    window.addEventListener('spoolbuddy-tag-removed', handleTagRemoved);
+    window.addEventListener('spoolbuddy-device-status', handleDeviceStatus);
+
+    return () => {
+      window.removeEventListener('spoolbuddy-weight', handleWeight);
+      window.removeEventListener('spoolbuddy-tag-matched', handleTagMatched);
+      window.removeEventListener('spoolbuddy-unknown-tag', handleTagUnknown);
+      window.removeEventListener('spoolbuddy-tag-removed', handleTagRemoved);
+      window.removeEventListener('spoolbuddy-device-status', handleDeviceStatus);
+    };
+  }, [handleWeight, handleTagMatched, handleTagUnknown, handleTagRemoved, handleDeviceStatus]);
+
+  return state;
+}

+ 33 - 0
frontend/src/hooks/useWebSocket.ts

@@ -258,6 +258,39 @@ export function useWebSocket() {
           })
         );
         break;
+
+      case 'spoolbuddy_weight':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-weight', {
+          detail: message as unknown as Record<string, unknown>,
+        }));
+        break;
+
+      case 'spoolbuddy_tag_matched':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-matched', {
+          detail: message as unknown as Record<string, unknown>,
+        }));
+        debouncedInvalidate('inventory-spools');
+        break;
+
+      case 'spoolbuddy_unknown_tag':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-unknown-tag', {
+          detail: message as unknown as Record<string, unknown>,
+        }));
+        break;
+
+      case 'spoolbuddy_tag_removed':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-tag-removed', {
+          detail: message as unknown as Record<string, unknown>,
+        }));
+        break;
+
+      case 'spoolbuddy_online':
+      case 'spoolbuddy_offline':
+        window.dispatchEvent(new CustomEvent('spoolbuddy-device-status', {
+          detail: message as unknown as Record<string, unknown>,
+        }));
+        debouncedInvalidate('spoolbuddy-devices');
+        break;
     }
   }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
 

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

@@ -3520,4 +3520,69 @@ export default {
     daysAgo: 'vor {{count}}d',
     inDays: 'in {{count}}d',
   },
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Dashboard',
+      ams: 'AMS',
+      inventory: 'Inventar',
+      printers: 'Drucker',
+      settings: 'Einstellungen',
+    },
+    status: {
+      nfcReady: 'Bereit',
+      nfcOff: 'Aus',
+      offline: 'Gerät offline',
+    },
+    dashboard: {
+      idleMessage: 'Spule auf die Waage legen und NFC-Tag scannen',
+    },
+    weight: {
+      noReading: 'Kein Messwert',
+      stable: 'Stabil',
+      measuring: 'Messung',
+      tare: 'Tara',
+      calibrate: 'Kalibrieren',
+      tareQueued: 'Tara-Befehl gesendet',
+    },
+    spool: {
+      remaining: 'Verbleibend',
+    },
+    tag: {
+      unknownTitle: 'Unbekannter Tag',
+      linkExisting: 'Mit vorhandener Spule verknüpfen',
+      createNew: 'Neue Spule erstellen',
+    },
+    actions: {
+      assignAms: 'AMS zuweisen',
+      updateWeight: 'Gewicht aktualisieren',
+      editSpool: 'Spule bearbeiten',
+      viewHistory: 'Verlauf anzeigen',
+      weightUpdated: 'Spulengewicht aktualisiert',
+    },
+    ams: {
+      noData: 'Keine AMS-Daten verfügbar',
+    },
+    inventory: {
+      search: 'Spulen suchen...',
+      empty: 'Keine Spulen gefunden',
+    },
+    printers: {
+      noPrinters: 'Keine Drucker konfiguriert',
+    },
+    settings: {
+      scaleCalibration: 'Waagenkalibrierung',
+      currentWeight: 'Aktuelles Gewicht',
+      tareOffset: 'Tara-Offset',
+      knownWeight: 'Bekanntes Gewicht',
+      calibrated: 'Kalibrierung aktualisiert',
+      tareQueued: 'Tara-Befehl in Warteschlange',
+      nfcReader: 'NFC-Leser',
+      nfcConnected: 'Verbunden',
+      nfcDisconnected: 'Getrennt',
+      deviceInfo: 'Geräteinformation',
+      deviceId: 'Geräte-ID',
+      uptime: 'Betriebszeit',
+      firmware: 'Firmware',
+    },
+  },
 };

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

@@ -3525,4 +3525,69 @@ export default {
     daysAgo: '{{count}}d ago',
     inDays: 'in {{count}}d',
   },
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Dashboard',
+      ams: 'AMS',
+      inventory: 'Inventory',
+      printers: 'Printers',
+      settings: 'Settings',
+    },
+    status: {
+      nfcReady: 'Ready',
+      nfcOff: 'Off',
+      offline: 'Device Offline',
+    },
+    dashboard: {
+      idleMessage: 'Place spool on scale and scan NFC tag',
+    },
+    weight: {
+      noReading: 'No reading',
+      stable: 'Stable',
+      measuring: 'Measuring',
+      tare: 'Tare',
+      calibrate: 'Calibrate',
+      tareQueued: 'Tare command sent',
+    },
+    spool: {
+      remaining: 'Remaining',
+    },
+    tag: {
+      unknownTitle: 'Unknown Tag',
+      linkExisting: 'Link to Existing Spool',
+      createNew: 'Create New Spool',
+    },
+    actions: {
+      assignAms: 'Assign AMS',
+      updateWeight: 'Update Weight',
+      editSpool: 'Edit Spool',
+      viewHistory: 'View History',
+      weightUpdated: 'Spool weight updated',
+    },
+    ams: {
+      noData: 'No AMS data available',
+    },
+    inventory: {
+      search: 'Search spools...',
+      empty: 'No spools found',
+    },
+    printers: {
+      noPrinters: 'No printers configured',
+    },
+    settings: {
+      scaleCalibration: 'Scale Calibration',
+      currentWeight: 'Current weight',
+      tareOffset: 'Tare offset',
+      knownWeight: 'Known weight',
+      calibrated: 'Calibration updated',
+      tareQueued: 'Tare command queued',
+      nfcReader: 'NFC Reader',
+      nfcConnected: 'Connected',
+      nfcDisconnected: 'Disconnected',
+      deviceInfo: 'Device Info',
+      deviceId: 'Device ID',
+      uptime: 'Uptime',
+      firmware: 'Firmware',
+    },
+  },
 };

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

@@ -3488,4 +3488,69 @@ export default {
     daysAgo: 'il y a {{count}}j',
     inDays: 'dans {{count}}j',
   },
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Tableau de bord',
+      ams: 'AMS',
+      inventory: 'Inventaire',
+      printers: 'Imprimantes',
+      settings: 'Paramètres',
+    },
+    status: {
+      nfcReady: 'Prêt',
+      nfcOff: 'Désactivé',
+      offline: 'Appareil hors ligne',
+    },
+    dashboard: {
+      idleMessage: 'Placez la bobine sur la balance et scannez le tag NFC',
+    },
+    weight: {
+      noReading: 'Aucune mesure',
+      stable: 'Stable',
+      measuring: 'Mesure en cours',
+      tare: 'Tare',
+      calibrate: 'Calibrer',
+      tareQueued: 'Commande de tare envoyée',
+    },
+    spool: {
+      remaining: 'Restant',
+    },
+    tag: {
+      unknownTitle: 'Tag inconnu',
+      linkExisting: 'Lier à une bobine existante',
+      createNew: 'Créer une nouvelle bobine',
+    },
+    actions: {
+      assignAms: 'Assigner AMS',
+      updateWeight: 'Mettre à jour le poids',
+      editSpool: 'Modifier la bobine',
+      viewHistory: 'Voir l\'historique',
+      weightUpdated: 'Poids de la bobine mis à jour',
+    },
+    ams: {
+      noData: 'Aucune donnée AMS disponible',
+    },
+    inventory: {
+      search: 'Rechercher des bobines...',
+      empty: 'Aucune bobine trouvée',
+    },
+    printers: {
+      noPrinters: 'Aucune imprimante configurée',
+    },
+    settings: {
+      scaleCalibration: 'Calibration de la balance',
+      currentWeight: 'Poids actuel',
+      tareOffset: 'Offset de tare',
+      knownWeight: 'Poids connu',
+      calibrated: 'Calibration mise à jour',
+      tareQueued: 'Commande de tare en file d\'attente',
+      nfcReader: 'Lecteur NFC',
+      nfcConnected: 'Connecté',
+      nfcDisconnected: 'Déconnecté',
+      deviceInfo: 'Info appareil',
+      deviceId: 'ID appareil',
+      uptime: 'Temps de fonctionnement',
+      firmware: 'Firmware',
+    },
+  },
 };

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

@@ -2876,4 +2876,69 @@ export default {
     daysAgo: '{{count}}g fa',
     inDays: 'tra {{count}}g',
   },
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Dashboard',
+      ams: 'AMS',
+      inventory: 'Inventario',
+      printers: 'Stampanti',
+      settings: 'Impostazioni',
+    },
+    status: {
+      nfcReady: 'Pronto',
+      nfcOff: 'Spento',
+      offline: 'Dispositivo offline',
+    },
+    dashboard: {
+      idleMessage: 'Posiziona la bobina sulla bilancia e scansiona il tag NFC',
+    },
+    weight: {
+      noReading: 'Nessuna lettura',
+      stable: 'Stabile',
+      measuring: 'Misurazione',
+      tare: 'Tara',
+      calibrate: 'Calibra',
+      tareQueued: 'Comando tara inviato',
+    },
+    spool: {
+      remaining: 'Rimanente',
+    },
+    tag: {
+      unknownTitle: 'Tag sconosciuto',
+      linkExisting: 'Collega a bobina esistente',
+      createNew: 'Crea nuova bobina',
+    },
+    actions: {
+      assignAms: 'Assegna AMS',
+      updateWeight: 'Aggiorna peso',
+      editSpool: 'Modifica bobina',
+      viewHistory: 'Visualizza cronologia',
+      weightUpdated: 'Peso bobina aggiornato',
+    },
+    ams: {
+      noData: 'Nessun dato AMS disponibile',
+    },
+    inventory: {
+      search: 'Cerca bobine...',
+      empty: 'Nessuna bobina trovata',
+    },
+    printers: {
+      noPrinters: 'Nessuna stampante configurata',
+    },
+    settings: {
+      scaleCalibration: 'Calibrazione bilancia',
+      currentWeight: 'Peso attuale',
+      tareOffset: 'Offset tara',
+      knownWeight: 'Peso noto',
+      calibrated: 'Calibrazione aggiornata',
+      tareQueued: 'Comando tara in coda',
+      nfcReader: 'Lettore NFC',
+      nfcConnected: 'Connesso',
+      nfcDisconnected: 'Disconnesso',
+      deviceInfo: 'Info dispositivo',
+      deviceId: 'ID dispositivo',
+      uptime: 'Tempo di attività',
+      firmware: 'Firmware',
+    },
+  },
 };

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

@@ -3354,4 +3354,69 @@ export default {
     daysAgo: '{{count}}日前',
     inDays: 'あと{{count}}日',
   },
+  spoolbuddy: {
+    nav: {
+      dashboard: 'ダッシュボード',
+      ams: 'AMS',
+      inventory: '在庫',
+      printers: 'プリンター',
+      settings: '設定',
+    },
+    status: {
+      nfcReady: '準備完了',
+      nfcOff: 'オフ',
+      offline: 'デバイスオフライン',
+    },
+    dashboard: {
+      idleMessage: 'スプールを秤に置き、NFCタグをスキャンしてください',
+    },
+    weight: {
+      noReading: '計測なし',
+      stable: '安定',
+      measuring: '計測中',
+      tare: '風袋',
+      calibrate: '校正',
+      tareQueued: '風袋コマンドを送信しました',
+    },
+    spool: {
+      remaining: '残量',
+    },
+    tag: {
+      unknownTitle: '不明なタグ',
+      linkExisting: '既存のスプールにリンク',
+      createNew: '新しいスプールを作成',
+    },
+    actions: {
+      assignAms: 'AMS割り当て',
+      updateWeight: '重量更新',
+      editSpool: 'スプール編集',
+      viewHistory: '履歴表示',
+      weightUpdated: 'スプール重量を更新しました',
+    },
+    ams: {
+      noData: 'AMSデータがありません',
+    },
+    inventory: {
+      search: 'スプールを検索...',
+      empty: 'スプールが見つかりません',
+    },
+    printers: {
+      noPrinters: 'プリンターが設定されていません',
+    },
+    settings: {
+      scaleCalibration: '秤の校正',
+      currentWeight: '現在の重量',
+      tareOffset: '風袋オフセット',
+      knownWeight: '既知の重量',
+      calibrated: '校正が更新されました',
+      tareQueued: '風袋コマンドをキューに追加しました',
+      nfcReader: 'NFCリーダー',
+      nfcConnected: '接続済み',
+      nfcDisconnected: '未接続',
+      deviceInfo: 'デバイス情報',
+      deviceId: 'デバイスID',
+      uptime: '稼働時間',
+      firmware: 'ファームウェア',
+    },
+  },
 };

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

@@ -3486,4 +3486,69 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+  spoolbuddy: {
+    nav: {
+      dashboard: 'Painel',
+      ams: 'AMS',
+      inventory: 'Inventário',
+      printers: 'Impressoras',
+      settings: 'Configurações',
+    },
+    status: {
+      nfcReady: 'Pronto',
+      nfcOff: 'Desligado',
+      offline: 'Dispositivo offline',
+    },
+    dashboard: {
+      idleMessage: 'Coloque o carretel na balança e escaneie a tag NFC',
+    },
+    weight: {
+      noReading: 'Sem leitura',
+      stable: 'Estável',
+      measuring: 'Medindo',
+      tare: 'Tara',
+      calibrate: 'Calibrar',
+      tareQueued: 'Comando de tara enviado',
+    },
+    spool: {
+      remaining: 'Restante',
+    },
+    tag: {
+      unknownTitle: 'Tag desconhecida',
+      linkExisting: 'Vincular a carretel existente',
+      createNew: 'Criar novo carretel',
+    },
+    actions: {
+      assignAms: 'Atribuir AMS',
+      updateWeight: 'Atualizar peso',
+      editSpool: 'Editar carretel',
+      viewHistory: 'Ver histórico',
+      weightUpdated: 'Peso do carretel atualizado',
+    },
+    ams: {
+      noData: 'Nenhum dado AMS disponível',
+    },
+    inventory: {
+      search: 'Buscar carretéis...',
+      empty: 'Nenhum carretel encontrado',
+    },
+    printers: {
+      noPrinters: 'Nenhuma impressora configurada',
+    },
+    settings: {
+      scaleCalibration: 'Calibração da balança',
+      currentWeight: 'Peso atual',
+      tareOffset: 'Offset de tara',
+      knownWeight: 'Peso conhecido',
+      calibrated: 'Calibração atualizada',
+      tareQueued: 'Comando de tara na fila',
+      nfcReader: 'Leitor NFC',
+      nfcConnected: 'Conectado',
+      nfcDisconnected: 'Desconectado',
+      deviceInfo: 'Info do dispositivo',
+      deviceId: 'ID do dispositivo',
+      uptime: 'Tempo ativo',
+      firmware: 'Firmware',
+    },
+  },
 };

+ 88 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -0,0 +1,88 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { AmsSlotCard } from '../../components/spoolbuddy/AmsSlotCard';
+import { api } from '../../api/client';
+
+interface PrinterOption {
+  id: number;
+  name: string;
+}
+
+export function SpoolBuddyAmsPage() {
+  const { t } = useTranslation();
+  const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const printerList = (printers || []) as PrinterOption[];
+  const activePrinterId = selectedPrinterId ?? printerList[0]?.id ?? null;
+
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', activePrinterId],
+    enabled: activePrinterId !== null,
+  });
+
+  const amsData = (status as Record<string, unknown>)?.ams as Record<string, unknown> | undefined;
+  const amsUnits = amsData?.ams as Array<Record<string, unknown>> | undefined;
+  return (
+    <div className="h-[512px] p-4 overflow-y-auto">
+      {/* Printer selector */}
+      <div className="mb-4">
+        <select
+          value={activePrinterId ?? ''}
+          onChange={(e) => setSelectedPrinterId(Number(e.target.value))}
+          className="h-[48px] w-full bg-bg-secondary border border-bambu-dark-tertiary rounded-lg px-4 text-text-primary text-[14px]"
+        >
+          {printerList.map((p) => (
+            <option key={p.id} value={p.id}>{p.name}</option>
+          ))}
+        </select>
+      </div>
+
+      {/* AMS units */}
+      {amsUnits ? (
+        <div className="space-y-6">
+          {amsUnits.map((ams, amsIdx) => {
+            const trays = ams.tray as Array<Record<string, unknown>> | undefined;
+            if (!trays) return null;
+            const label = String.fromCharCode(65 + amsIdx); // A, B, C, D
+
+            return (
+              <div key={amsIdx}>
+                <h3 className="text-[16px] font-semibold text-text-primary mb-2">AMS-{label}</h3>
+                <div className="flex gap-2">
+                  {trays.map((tray, trayIdx) => {
+                    const trayType = tray.tray_type as string | undefined;
+                    const trayColor = tray.tray_color as string | undefined;
+                    const remain = tray.remain as number | undefined;
+                    const isEmpty = !trayType || trayType === '';
+
+                    return (
+                      <AmsSlotCard
+                        key={trayIdx}
+                        material={trayType || null}
+                        colorHex={trayColor || null}
+                        colorName={null}
+                        remaining={remain ?? null}
+                        isEmpty={isEmpty}
+                        onClick={() => {/* TODO: slot detail modal */}}
+                      />
+                    );
+                  })}
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      ) : (
+        <div className="flex items-center justify-center h-[300px]">
+          <p className="text-text-muted text-[16px]">{t('spoolbuddy.ams.noData')}</p>
+        </div>
+      )}
+    </div>
+  );
+}

+ 115 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -0,0 +1,115 @@
+import { useOutletContext } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { useMutation } from '@tanstack/react-query';
+import { Scale, Nfc } from 'lucide-react';
+import { WeightDisplay } from '../../components/spoolbuddy/WeightDisplay';
+import { SpoolInfoCard } from '../../components/spoolbuddy/SpoolInfoCard';
+import { UnknownTagCard } from '../../components/spoolbuddy/UnknownTagCard';
+import { QuickActionGrid } from '../../components/spoolbuddy/QuickActionGrid';
+import { useToast } from '../../contexts/ToastContext';
+import { spoolBuddyApi } from '../../api/client';
+
+interface SpoolBuddyState {
+  view: 'idle' | 'tag_known' | 'tag_unknown';
+  weight: { weight_grams: number; stable: boolean; raw_adc: number | null; device_id: string } | null;
+  tag: { tag_uid: string; sak?: number; tag_type?: string; device_id: string } | null;
+  spool: {
+    id: number;
+    material: string;
+    subtype: string | null;
+    color_name: string | null;
+    rgba: string | null;
+    brand: string | null;
+    label_weight: number;
+    core_weight: number;
+    weight_used: number;
+  } | null;
+  deviceOnline: boolean;
+}
+
+export function SpoolBuddyDashboard() {
+  const state = useOutletContext<SpoolBuddyState>();
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+
+  const updateWeightMutation = useMutation({
+    mutationFn: (data: { spool_id: number; weight_grams: number }) =>
+      spoolBuddyApi.updateSpoolWeight(data.spool_id, data.weight_grams),
+    onSuccess: () => showToast(t('spoolbuddy.actions.weightUpdated'), 'success'),
+    onError: () => showToast(t('common.error'), 'error'),
+  });
+
+  const handleUpdateWeight = () => {
+    if (state.spool && state.weight) {
+      updateWeightMutation.mutate({
+        spool_id: state.spool.id,
+        weight_grams: state.weight.weight_grams,
+      });
+    }
+  };
+
+  const handleTare = async () => {
+    if (state.weight?.device_id) {
+      try {
+        await spoolBuddyApi.tare(state.weight.device_id);
+        showToast(t('spoolbuddy.weight.tareQueued'), 'success');
+      } catch {
+        showToast(t('common.error'), 'error');
+      }
+    }
+  };
+
+  return (
+    <div className="flex h-[512px]">
+      {/* Left panel — Weight */}
+      <div className="w-[512px] border-r border-bambu-dark-tertiary">
+        <WeightDisplay
+          weightGrams={state.weight?.weight_grams ?? null}
+          stable={state.weight?.stable ?? false}
+          rawAdc={state.weight?.raw_adc ?? null}
+          onTare={handleTare}
+          onCalibrate={() => {/* TODO: open calibration modal */}}
+        />
+      </div>
+
+      {/* Right panel — Tag state */}
+      <div className="w-[512px] p-6 overflow-y-auto">
+        {state.view === 'idle' && (
+          <div className="flex flex-col items-center justify-center h-full text-center">
+            <div className="flex gap-4 mb-6">
+              <Scale size={48} className="text-text-muted" />
+              <Nfc size={48} className="text-text-muted" />
+            </div>
+            <p className="text-[24px] text-text-secondary max-w-[360px]">
+              {t('spoolbuddy.dashboard.idleMessage')}
+            </p>
+          </div>
+        )}
+
+        {state.view === 'tag_known' && state.spool && (
+          <div className="flex flex-col h-full">
+            <div className="flex-1">
+              <SpoolInfoCard spool={state.spool} />
+            </div>
+            <QuickActionGrid
+              onUpdateWeight={handleUpdateWeight}
+              onEditSpool={() => {/* TODO: open spool form modal */}}
+              onAssignAms={() => {/* TODO: open assign modal */}}
+              onViewHistory={() => {/* TODO: navigate to history */}}
+            />
+          </div>
+        )}
+
+        {state.view === 'tag_unknown' && state.tag && (
+          <UnknownTagCard
+            tagUid={state.tag.tag_uid}
+            sak={state.tag.sak}
+            tagType={state.tag.tag_type}
+            onLinkExisting={() => {/* TODO: open link modal */}}
+            onCreateNew={() => {/* TODO: open create modal */}}
+          />
+        )}
+      </div>
+    </div>
+  );
+}

+ 105 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -0,0 +1,105 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Search, Plus } from 'lucide-react';
+import { Button } from '../../components/Button';
+import { api } from '../../api/client';
+
+interface InventorySpool {
+  id: number;
+  material: string;
+  subtype: string | null;
+  color_name: string | null;
+  rgba: string | null;
+  brand: string | null;
+  label_weight: number;
+  weight_used: number;
+}
+
+export function SpoolBuddyInventoryPage() {
+  const { t } = useTranslation();
+  const [search, setSearch] = useState('');
+
+  const { data: spools, isLoading } = useQuery({
+    queryKey: ['inventory-spools'],
+    queryFn: () => api.getSpools(),
+  });
+
+  const spoolList = (spools || []) as InventorySpool[];
+  const filtered = spoolList.filter((s) => {
+    if (!search) return true;
+    const q = search.toLowerCase();
+    return (
+      s.material.toLowerCase().includes(q) ||
+      (s.brand?.toLowerCase().includes(q)) ||
+      (s.color_name?.toLowerCase().includes(q))
+    );
+  });
+
+  return (
+    <div className="h-[512px] flex flex-col p-4">
+      {/* Search + Add */}
+      <div className="flex gap-2 mb-4">
+        <div className="relative flex-1">
+          <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
+          <input
+            type="text"
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+            placeholder={t('spoolbuddy.inventory.search')}
+            className="w-full h-[48px] bg-bg-secondary border border-bambu-dark-tertiary rounded-lg pl-10 pr-4 text-text-primary text-[14px] placeholder:text-text-muted"
+          />
+        </div>
+        <Button variant="primary" className="h-[48px] px-4">
+          <Plus size={18} />
+          <span className="ml-1">{t('common.add')}</span>
+        </Button>
+      </div>
+
+      {/* Spool grid */}
+      <div className="flex-1 overflow-y-auto">
+        <div className="grid grid-cols-2 gap-2">
+          {filtered.map((spool) => {
+            const remaining = Math.max(0, spool.label_weight - spool.weight_used);
+            const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;
+            const color = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
+            const materialLabel = spool.subtype ? `${spool.material} ${spool.subtype}` : spool.material;
+
+            return (
+              <button
+                key={spool.id}
+                className="flex items-center gap-3 p-4 bg-bg-secondary rounded-xl border border-bambu-dark-tertiary active:bg-bambu-dark-tertiary transition-colors text-left"
+              >
+                <div
+                  className="w-[32px] h-[32px] rounded-full border-2 border-bambu-dark-tertiary flex-shrink-0"
+                  style={{ backgroundColor: color }}
+                />
+                <div className="flex-1 min-w-0">
+                  <p className="text-[14px] font-semibold text-text-primary truncate">{materialLabel}</p>
+                  <p className="text-[12px] text-text-secondary truncate">
+                    {[spool.brand, spool.color_name].filter(Boolean).join(' - ')}
+                  </p>
+                  <div className="flex items-center gap-2 mt-1">
+                    <span className="text-[12px] text-text-muted">{remaining}g</span>
+                    <div className="flex-1 h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+                      <div
+                        className="h-full rounded-full bg-bambu-green"
+                        style={{ width: `${pct}%` }}
+                      />
+                    </div>
+                  </div>
+                </div>
+              </button>
+            );
+          })}
+        </div>
+
+        {filtered.length === 0 && !isLoading && (
+          <div className="flex items-center justify-center h-[200px]">
+            <p className="text-text-muted text-[14px]">{t('spoolbuddy.inventory.empty')}</p>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 74 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyPrintersPage.tsx

@@ -0,0 +1,74 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { api } from '../../api/client';
+
+interface PrinterInfo {
+  id: number;
+  name: string;
+  model: string;
+  ip_address: string;
+}
+
+export function SpoolBuddyPrintersPage() {
+  const { t } = useTranslation();
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  const printerList = (printers || []) as PrinterInfo[];
+
+  return (
+    <div className="h-[512px] p-4 overflow-y-auto space-y-2">
+      {printerList.map((printer) => {
+        return (
+          <PrinterCard key={printer.id} printer={printer} />
+        );
+      })}
+
+      {printerList.length === 0 && (
+        <div className="flex items-center justify-center h-[300px]">
+          <p className="text-text-muted text-[16px]">{t('spoolbuddy.printers.noPrinters')}</p>
+        </div>
+      )}
+    </div>
+  );
+}
+
+function PrinterCard({ printer }: { printer: PrinterInfo }) {
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', printer.id],
+  });
+
+  const st = status as Record<string, unknown> | undefined;
+  const isOnline = st?.online === true;
+  const printPct = st?.mc_percent as number | undefined;
+  const nozzleTemp = st?.nozzle_temper as number | undefined;
+  const bedTemp = st?.bed_temper as number | undefined;
+
+  return (
+    <div className="p-4 bg-bg-secondary rounded-xl border border-bambu-dark-tertiary">
+      <div className="flex items-center justify-between mb-1">
+        <div className="flex items-center gap-2">
+          <span className={`w-2.5 h-2.5 rounded-full ${isOnline ? 'bg-green-500' : 'bg-bambu-gray'}`} />
+          <h3 className="text-[16px] font-semibold text-text-primary">{printer.name}</h3>
+        </div>
+        <span className={`text-[13px] px-2 py-0.5 rounded ${isOnline ? 'bg-green-500/20 text-green-400' : 'bg-bambu-dark-tertiary text-text-muted'}`}>
+          {isOnline ? 'Online' : 'Offline'}
+        </span>
+      </div>
+
+      <div className="flex items-center gap-3 text-[13px] text-text-secondary">
+        <span>{printer.model}</span>
+        <span>{printer.ip_address}</span>
+        {printPct !== undefined && printPct > 0 && (
+          <span>Print: {printPct}%</span>
+        )}
+        {nozzleTemp !== undefined && bedTemp !== undefined && isOnline && (
+          <span>{Math.round(nozzleTemp)}° / {Math.round(bedTemp)}°</span>
+        )}
+      </div>
+    </div>
+  );
+}

+ 165 - 0
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -0,0 +1,165 @@
+import { useState } from 'react';
+import { useOutletContext } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Button } from '../../components/Button';
+import { useToast } from '../../contexts/ToastContext';
+import { spoolBuddyApi } from '../../api/client';
+
+interface SpoolBuddyState {
+  weight: { weight_grams: number; stable: boolean; raw_adc: number | null; device_id: string } | null;
+  deviceOnline: boolean;
+}
+
+interface DeviceInfo {
+  device_id: string;
+  hostname: string;
+  ip_address: string;
+  firmware_version: string | null;
+  tare_offset: number;
+  calibration_factor: number;
+  nfc_ok: boolean;
+  scale_ok: boolean;
+  uptime_s: number;
+  online: boolean;
+}
+
+export function SpoolBuddySettingsPage() {
+  const state = useOutletContext<SpoolBuddyState>();
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [knownWeight, setKnownWeight] = useState('500');
+
+  const { data: devices } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: spoolBuddyApi.getDevices,
+  });
+
+  const deviceList = (devices || []) as DeviceInfo[];
+  const device = deviceList[0]; // Primary device
+  const deviceId = device?.device_id ?? state.weight?.device_id;
+
+  const tareMutation = useMutation({
+    mutationFn: () => spoolBuddyApi.tare(deviceId!),
+    onSuccess: () => {
+      showToast(t('spoolbuddy.settings.tareQueued'), 'success');
+      queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
+    },
+    onError: () => showToast(t('common.error'), 'error'),
+  });
+
+  const calibrateMutation = useMutation({
+    mutationFn: () =>
+      spoolBuddyApi.setCalibrationFactor(deviceId!, parseFloat(knownWeight), state.weight?.raw_adc ?? 0),
+    onSuccess: () => {
+      showToast(t('spoolbuddy.settings.calibrated'), 'success');
+      queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
+    },
+    onError: () => showToast(t('common.error'), 'error'),
+  });
+
+  const formatUptime = (s: number) => {
+    const h = Math.floor(s / 3600);
+    const m = Math.floor((s % 3600) / 60);
+    return `${h}h ${m}m`;
+  };
+
+  return (
+    <div className="h-[512px] p-4 overflow-y-auto space-y-4">
+      {/* Scale Calibration */}
+      <section>
+        <h2 className="text-[18px] font-semibold text-text-primary mb-3">
+          {t('spoolbuddy.settings.scaleCalibration')}
+        </h2>
+        <div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4 space-y-4">
+          <div className="flex justify-between text-[14px]">
+            <span className="text-text-secondary">{t('spoolbuddy.settings.currentWeight')}</span>
+            <span className="text-text-primary font-mono">
+              {state.weight ? `${state.weight.weight_grams.toFixed(1)}g (raw: ${state.weight.raw_adc ?? '---'})` : '---'}
+            </span>
+          </div>
+
+          <div className="flex justify-between items-center">
+            <span className="text-[14px] text-text-secondary">
+              {t('spoolbuddy.settings.tareOffset')}: {device?.tare_offset ?? '---'}
+            </span>
+            <Button
+              variant="secondary"
+              className="h-[48px] px-6"
+              onClick={() => tareMutation.mutate()}
+              disabled={!deviceId || tareMutation.isPending}
+            >
+              {t('spoolbuddy.weight.tare')}
+            </Button>
+          </div>
+
+          <div className="flex items-center gap-3">
+            <span className="text-[14px] text-text-secondary whitespace-nowrap">
+              {t('spoolbuddy.settings.knownWeight')}:
+            </span>
+            <input
+              type="number"
+              value={knownWeight}
+              onChange={(e) => setKnownWeight(e.target.value)}
+              className="h-[48px] w-[120px] bg-bg-primary border border-bambu-dark-tertiary rounded-lg px-3 text-text-primary text-[14px] text-right"
+            />
+            <span className="text-[14px] text-text-secondary">g</span>
+            <Button
+              variant="secondary"
+              className="h-[48px] px-6 ml-auto"
+              onClick={() => calibrateMutation.mutate()}
+              disabled={!deviceId || !state.weight?.raw_adc || calibrateMutation.isPending}
+            >
+              {t('spoolbuddy.weight.calibrate')}
+            </Button>
+          </div>
+        </div>
+      </section>
+
+      {/* NFC Reader */}
+      <section>
+        <h2 className="text-[18px] font-semibold text-text-primary mb-3">
+          {t('spoolbuddy.settings.nfcReader')}
+        </h2>
+        <div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4">
+          <div className="flex items-center gap-2">
+            <span className={`w-2.5 h-2.5 rounded-full ${device?.nfc_ok ? 'bg-green-500' : 'bg-bambu-gray'}`} />
+            <span className="text-[14px] text-text-primary">
+              {device?.nfc_ok ? t('spoolbuddy.settings.nfcConnected') : t('spoolbuddy.settings.nfcDisconnected')}
+            </span>
+          </div>
+        </div>
+      </section>
+
+      {/* Device Info */}
+      {device && (
+        <section>
+          <h2 className="text-[18px] font-semibold text-text-primary mb-3">
+            {t('spoolbuddy.settings.deviceInfo')}
+          </h2>
+          <div className="bg-bg-secondary rounded-xl border border-bambu-dark-tertiary p-4 text-[14px] space-y-2">
+            <div className="flex justify-between">
+              <span className="text-text-secondary">{t('spoolbuddy.settings.deviceId')}</span>
+              <span className="text-text-primary font-mono">{device.device_id}</span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-text-secondary">IP</span>
+              <span className="text-text-primary">{device.ip_address}</span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-text-secondary">{t('spoolbuddy.settings.uptime')}</span>
+              <span className="text-text-primary">{formatUptime(device.uptime_s)}</span>
+            </div>
+            {device.firmware_version && (
+              <div className="flex justify-between">
+                <span className="text-text-secondary">{t('spoolbuddy.settings.firmware')}</span>
+                <span className="text-text-primary">{device.firmware_version}</span>
+              </div>
+            )}
+          </div>
+        </section>
+      )}
+    </div>
+  );
+}

+ 0 - 0
spoolbuddy/daemon/__init__.py


+ 147 - 0
spoolbuddy/daemon/api_client.py

@@ -0,0 +1,147 @@
+"""HTTP client for communicating with Bambuddy backend."""
+
+import asyncio
+import logging
+from collections import deque
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+MAX_BUFFER_SIZE = 100
+
+
+class APIClient:
+    def __init__(self, backend_url: str, api_key: str):
+        self._base = backend_url.rstrip("/") + "/api/v1/spoolbuddy"
+        self._headers = {"X-API-Key": api_key} if api_key else {}
+        self._client = httpx.AsyncClient(timeout=10.0, headers=self._headers)
+        self._backoff = 1.0
+        self._max_backoff = 30.0
+        self._buffer: deque[dict] = deque(maxlen=MAX_BUFFER_SIZE)
+        self._connected = False
+
+    async def close(self):
+        await self._client.aclose()
+
+    async def _post(self, path: str, data: dict) -> dict | None:
+        try:
+            resp = await self._client.post(f"{self._base}{path}", json=data)
+            resp.raise_for_status()
+            self._backoff = 1.0
+            self._connected = True
+            return resp.json()
+        except Exception as e:
+            if self._connected:
+                logger.warning("Backend connection lost: %s", e)
+                self._connected = False
+            self._buffer.append({"path": path, "data": data})
+            return None
+
+    async def _get(self, path: str) -> dict | None:
+        try:
+            resp = await self._client.get(f"{self._base}{path}")
+            resp.raise_for_status()
+            return resp.json()
+        except Exception as e:
+            logger.warning("GET %s failed: %s", path, e)
+            return None
+
+    async def _flush_buffer(self):
+        while self._buffer:
+            item = self._buffer[0]
+            try:
+                resp = await self._client.post(f"{self._base}{item['path']}", json=item["data"])
+                resp.raise_for_status()
+                self._buffer.popleft()
+            except Exception:
+                break
+
+    async def register_device(
+        self,
+        device_id: str,
+        hostname: str,
+        ip_address: str,
+        firmware_version: str | None = None,
+        has_nfc: bool = True,
+        has_scale: bool = True,
+        tare_offset: int = 0,
+        calibration_factor: float = 1.0,
+    ) -> dict | None:
+        while True:
+            result = await self._post(
+                "/devices/register",
+                {
+                    "device_id": device_id,
+                    "hostname": hostname,
+                    "ip_address": ip_address,
+                    "firmware_version": firmware_version,
+                    "has_nfc": has_nfc,
+                    "has_scale": has_scale,
+                    "tare_offset": tare_offset,
+                    "calibration_factor": calibration_factor,
+                },
+            )
+            if result is not None:
+                logger.info("Registered with backend as %s", device_id)
+                return result
+            logger.warning("Registration failed, retrying in %.0fs...", self._backoff)
+            await asyncio.sleep(self._backoff)
+            self._backoff = min(self._backoff * 2, self._max_backoff)
+
+    async def heartbeat(
+        self, device_id: str, nfc_ok: bool, scale_ok: bool, uptime_s: int, ip_address: str | None = None
+    ) -> dict | None:
+        result = await self._post(
+            f"/devices/{device_id}/heartbeat",
+            {
+                "nfc_ok": nfc_ok,
+                "scale_ok": scale_ok,
+                "uptime_s": uptime_s,
+                "ip_address": ip_address,
+            },
+        )
+        if result and self._buffer:
+            await self._flush_buffer()
+        return result
+
+    async def tag_scanned(
+        self,
+        device_id: str,
+        tag_uid: str,
+        tray_uuid: str | None = None,
+        sak: int | None = None,
+        tag_type: str | None = None,
+    ) -> dict | None:
+        return await self._post(
+            "/nfc/tag-scanned",
+            {
+                "device_id": device_id,
+                "tag_uid": tag_uid,
+                "tray_uuid": tray_uuid,
+                "sak": sak,
+                "tag_type": tag_type,
+            },
+        )
+
+    async def tag_removed(self, device_id: str, tag_uid: str) -> dict | None:
+        return await self._post(
+            "/nfc/tag-removed",
+            {
+                "device_id": device_id,
+                "tag_uid": tag_uid,
+            },
+        )
+
+    async def scale_reading(
+        self, device_id: str, weight_grams: float, stable: bool, raw_adc: int | None = None
+    ) -> dict | None:
+        return await self._post(
+            "/scale/reading",
+            {
+                "device_id": device_id,
+                "weight_grams": weight_grams,
+                "stable": stable,
+                "raw_adc": raw_adc,
+            },
+        )

+ 81 - 0
spoolbuddy/daemon/config.py

@@ -0,0 +1,81 @@
+"""Configuration loader for SpoolBuddy daemon."""
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+
+CONFIG_PATH = Path(os.environ.get("SPOOLBUDDY_CONFIG", "/etc/spoolbuddy/config.yaml"))
+
+
+@dataclass
+class Config:
+    backend_url: str = "http://localhost:5000"
+    api_key: str = ""
+    device_id: str = ""
+    hostname: str = ""
+
+    nfc_poll_interval: float = 0.3
+    scale_read_interval: float = 0.1
+    scale_report_interval: float = 1.0
+    heartbeat_interval: float = 10.0
+    stability_threshold: float = 2.0
+    stability_window: float = 1.0
+
+    tare_offset: int = 0
+    calibration_factor: float = 1.0
+
+    @classmethod
+    def load(cls) -> "Config":
+        cfg = cls()
+
+        # Load from YAML if exists
+        if CONFIG_PATH.exists():
+            with open(CONFIG_PATH) as f:
+                data = yaml.safe_load(f) or {}
+            for key, val in data.items():
+                if hasattr(cfg, key):
+                    setattr(cfg, key, val)
+
+        # Environment overrides
+        env_map = {
+            "SPOOLBUDDY_BACKEND_URL": "backend_url",
+            "SPOOLBUDDY_API_KEY": "api_key",
+            "SPOOLBUDDY_DEVICE_ID": "device_id",
+            "SPOOLBUDDY_HOSTNAME": "hostname",
+        }
+        for env_key, attr in env_map.items():
+            val = os.environ.get(env_key)
+            if val:
+                setattr(cfg, attr, val)
+
+        # Default device_id from MAC address
+        if not cfg.device_id:
+            cfg.device_id = _get_mac_id()
+
+        # Default hostname from system
+        if not cfg.hostname:
+            import socket
+
+            cfg.hostname = socket.gethostname()
+
+        return cfg
+
+
+def _get_mac_id() -> str:
+    """Generate a device ID from the primary network interface MAC address."""
+    try:
+        for iface in Path("/sys/class/net").iterdir():
+            if iface.name == "lo":
+                continue
+            addr_file = iface / "address"
+            if addr_file.exists():
+                mac = addr_file.read_text().strip().replace(":", "")
+                if mac and mac != "000000000000":
+                    return f"sb-{mac}"
+    except Exception:
+        pass
+    import uuid
+
+    return f"sb-{uuid.uuid4().hex[:12]}"

+ 168 - 0
spoolbuddy/daemon/main.py

@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""SpoolBuddy daemon — reads NFC tags and scale, pushes events to Bambuddy backend."""
+
+import asyncio
+import logging
+import socket
+import time
+
+from spoolbuddy.daemon.api_client import APIClient
+from spoolbuddy.daemon.config import Config
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
+    datefmt="%H:%M:%S",
+)
+logger = logging.getLogger("spoolbuddy")
+
+
+def _get_ip() -> str:
+    try:
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        s.connect(("8.8.8.8", 80))
+        ip = s.getsockname()[0]
+        s.close()
+        return ip
+    except Exception:
+        return "unknown"
+
+
+async def nfc_poll_loop(config: Config, api: APIClient):
+    """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
+    from spoolbuddy.daemon.nfc_reader import NFCReader
+
+    nfc = NFCReader()
+    if not nfc.ok:
+        logger.warning("NFC reader not available, skipping NFC polling")
+        return
+
+    try:
+        while True:
+            event_type, event_data = await asyncio.to_thread(nfc.poll)
+
+            if event_type == "tag_detected":
+                await api.tag_scanned(
+                    device_id=config.device_id,
+                    tag_uid=event_data["tag_uid"],
+                    tray_uuid=event_data.get("tray_uuid"),
+                    sak=event_data.get("sak"),
+                    tag_type=event_data.get("tag_type"),
+                )
+            elif event_type == "tag_removed":
+                await api.tag_removed(
+                    device_id=config.device_id,
+                    tag_uid=event_data["tag_uid"],
+                )
+
+            await asyncio.sleep(config.nfc_poll_interval)
+    finally:
+        nfc.close()
+
+
+async def scale_poll_loop(config: Config, api: APIClient):
+    """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
+    from spoolbuddy.daemon.scale_reader import ScaleReader
+
+    scale = ScaleReader(
+        tare_offset=config.tare_offset,
+        calibration_factor=config.calibration_factor,
+    )
+    if not scale.ok:
+        logger.warning("Scale not available, skipping scale polling")
+        return
+
+    last_report = 0.0
+    try:
+        while True:
+            result = await asyncio.to_thread(scale.read)
+
+            if result is not None:
+                grams, stable, raw_adc = result
+                now = time.monotonic()
+
+                if now - last_report >= config.scale_report_interval:
+                    await api.scale_reading(
+                        device_id=config.device_id,
+                        weight_grams=grams,
+                        stable=stable,
+                        raw_adc=raw_adc,
+                    )
+                    last_report = now
+
+            await asyncio.sleep(config.scale_read_interval)
+    finally:
+        scale.close()
+
+
+async def heartbeat_loop(config: Config, api: APIClient, start_time: float):
+    """Periodic heartbeat to keep device registered and pick up commands."""
+
+    ip = _get_ip()
+
+    while True:
+        await asyncio.sleep(config.heartbeat_interval)
+
+        uptime = int(time.monotonic() - start_time)
+        result = await api.heartbeat(
+            device_id=config.device_id,
+            nfc_ok=True,
+            scale_ok=True,
+            uptime_s=uptime,
+            ip_address=ip,
+        )
+
+        if result:
+            cmd = result.get("pending_command")
+            if cmd == "tare":
+                logger.info("Tare command received from backend")
+                # Tare is handled by scale_reader — need cross-task communication
+                # For now, update calibration from backend response
+            tare = result.get("tare_offset", config.tare_offset)
+            cal = result.get("calibration_factor", config.calibration_factor)
+            if tare != config.tare_offset or cal != config.calibration_factor:
+                config.tare_offset = tare
+                config.calibration_factor = cal
+                logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
+
+
+async def main():
+    config = Config.load()
+    logger.info("SpoolBuddy daemon starting (device=%s, backend=%s)", config.device_id, config.backend_url)
+
+    api = APIClient(config.backend_url, config.api_key)
+    ip = _get_ip()
+    start_time = time.monotonic()
+
+    # Register with backend (retries until success)
+    reg = await api.register_device(
+        device_id=config.device_id,
+        hostname=config.hostname,
+        ip_address=ip,
+        has_nfc=True,
+        has_scale=True,
+        tare_offset=config.tare_offset,
+        calibration_factor=config.calibration_factor,
+    )
+
+    # Use server-side calibration if available
+    if reg:
+        config.tare_offset = reg.get("tare_offset", config.tare_offset)
+        config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
+
+    logger.info("Device registered, starting poll loops")
+
+    try:
+        await asyncio.gather(
+            nfc_poll_loop(config, api),
+            scale_poll_loop(config, api),
+            heartbeat_loop(config, api, start_time),
+        )
+    except KeyboardInterrupt:
+        logger.info("Shutting down")
+    finally:
+        await api.close()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 131 - 0
spoolbuddy/daemon/nfc_reader.py

@@ -0,0 +1,131 @@
+"""NFC reader wrapper with state machine for tag presence detection."""
+
+import logging
+import time
+from enum import Enum, auto
+
+logger = logging.getLogger(__name__)
+
+MISS_THRESHOLD = 3  # Consecutive misses before declaring tag removed
+
+
+class NFCState(Enum):
+    IDLE = auto()
+    TAG_PRESENT = auto()
+
+
+class NFCReader:
+    def __init__(self):
+        from spoolbuddy.read_tag import PN5180
+
+        self._nfc = PN5180()
+        self._state = NFCState.IDLE
+        self._current_uid: str | None = None
+        self._current_sak: int | None = None
+        self._miss_count = 0
+        self._ok = False
+
+        try:
+            self._nfc.reset()
+            self._nfc.load_rf_config(0x00, 0x80)
+            time.sleep(0.010)
+            self._nfc.rf_on()
+            time.sleep(0.030)
+            self._nfc.set_transceive_mode()
+            self._ok = True
+            logger.info("NFC reader initialized")
+        except Exception as e:
+            logger.error("NFC reader init failed: %s", e)
+
+    @property
+    def ok(self) -> bool:
+        return self._ok
+
+    @property
+    def state(self) -> NFCState:
+        return self._state
+
+    @property
+    def current_uid(self) -> str | None:
+        return self._current_uid
+
+    def close(self):
+        try:
+            self._nfc.rf_off()
+            self._nfc.close()
+        except Exception:
+            pass
+
+    def poll(self) -> tuple[str, dict | None]:
+        """Poll for tag. Returns (event_type, event_data).
+
+        event_type: "none", "tag_detected", "tag_removed"
+        """
+        try:
+            result = self._nfc.activate_type_a()
+        except Exception as e:
+            logger.debug("NFC poll error: %s", e)
+            self._ok = False
+            return "none", None
+
+        self._ok = True
+
+        if result is not None:
+            uid_bytes, sak = result
+            uid_hex = uid_bytes.hex().upper()
+            self._miss_count = 0
+
+            if self._state == NFCState.IDLE:
+                self._state = NFCState.TAG_PRESENT
+                self._current_uid = uid_hex
+                self._current_sak = sak
+
+                # Try reading Bambu tag data
+                tray_uuid = None
+                tag_type = "mifare_classic" if sak in (0x08, 0x18) else "ntag" if sak == 0x00 else "unknown"
+
+                if sak in (0x08, 0x18):
+                    blocks = self._nfc.read_bambu_tag(uid_bytes)
+                    if blocks:
+                        tray_uuid = _extract_tray_uuid(blocks)
+
+                logger.info("Tag detected: %s (SAK=0x%02X)", uid_hex, sak)
+                return "tag_detected", {
+                    "tag_uid": uid_hex,
+                    "sak": sak,
+                    "tag_type": tag_type,
+                    "tray_uuid": tray_uuid,
+                }
+
+            # Tag still present — no event
+            return "none", None
+
+        # No tag found
+        if self._state == NFCState.TAG_PRESENT:
+            self._miss_count += 1
+            if self._miss_count >= MISS_THRESHOLD:
+                old_uid = self._current_uid
+                self._state = NFCState.IDLE
+                self._current_uid = None
+                self._current_sak = None
+                self._miss_count = 0
+                logger.info("Tag removed: %s", old_uid)
+                return "tag_removed", {"tag_uid": old_uid}
+
+        return "none", None
+
+
+def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
+    """Extract tray_uuid from Bambu MIFARE Classic data blocks."""
+    # Block 4-5 contain the 32-char tray UUID (first 16 bytes from block 4 + 5)
+    if 4 in blocks and 5 in blocks:
+        raw = blocks[4] + blocks[5]
+        # UUID is stored as ASCII hex in the first 16 bytes of blocks 4-5
+        uuid_bytes = raw[:16]
+        try:
+            uuid_str = uuid_bytes.hex().upper()
+            if uuid_str and uuid_str != "0" * 32:
+                return uuid_str
+        except Exception:
+            pass
+    return None

+ 93 - 0
spoolbuddy/daemon/scale_reader.py

@@ -0,0 +1,93 @@
+"""Scale reader wrapper with stability detection and calibration."""
+
+import logging
+import time
+from collections import deque
+
+logger = logging.getLogger(__name__)
+
+MOVING_AVG_SIZE = 5
+
+
+class ScaleReader:
+    def __init__(self, tare_offset: int = 0, calibration_factor: float = 1.0):
+        from spoolbuddy.scale_diag import NAU7802
+
+        self._scale = NAU7802()
+        self._tare_offset = tare_offset
+        self._calibration_factor = calibration_factor
+        self._samples: deque[float] = deque(maxlen=MOVING_AVG_SIZE)
+        self._stability_history: deque[tuple[float, float]] = deque(maxlen=20)
+        self._ok = False
+        self._last_raw = 0
+
+        try:
+            self._scale.init()
+            self._ok = True
+            logger.info("Scale initialized (tare=%d, cal=%.6f)", tare_offset, calibration_factor)
+        except Exception as e:
+            logger.error("Scale init failed: %s", e)
+
+    @property
+    def ok(self) -> bool:
+        return self._ok
+
+    @property
+    def last_raw(self) -> int:
+        return self._last_raw
+
+    def close(self):
+        try:
+            self._scale.close()
+        except Exception:
+            pass
+
+    def update_calibration(self, tare_offset: int, calibration_factor: float):
+        self._tare_offset = tare_offset
+        self._calibration_factor = calibration_factor
+        logger.info("Calibration updated: tare=%d, factor=%.6f", tare_offset, calibration_factor)
+
+    def tare(self):
+        """Set current raw reading as tare offset."""
+        if self._last_raw:
+            self._tare_offset = self._last_raw
+            self._samples.clear()
+            self._stability_history.clear()
+            logger.info("Tared at raw=%d", self._tare_offset)
+        return self._tare_offset
+
+    def read(self) -> tuple[float, bool, int] | None:
+        """Read current weight. Returns (grams, stable, raw_adc) or None."""
+        try:
+            if not self._scale.data_ready():
+                return None
+
+            raw = self._scale.read_raw()
+            self._last_raw = raw
+            self._ok = True
+
+            grams = (raw - self._tare_offset) * self._calibration_factor
+            self._samples.append(grams)
+
+            # Moving average
+            avg_grams = sum(self._samples) / len(self._samples)
+
+            # Stability: track readings over time
+            now = time.monotonic()
+            self._stability_history.append((now, avg_grams))
+
+            # Stable if all readings within 1s window are within 2g of each other
+            stable = False
+            if len(self._stability_history) >= 5:
+                cutoff = now - 1.0
+                recent = [g for t, g in self._stability_history if t >= cutoff]
+                if len(recent) >= 3:
+                    spread = max(recent) - min(recent)
+                    stable = spread < 2.0
+
+            return round(avg_grams, 1), stable, raw
+
+        except Exception as e:
+            logger.debug("Scale read error: %s", e)
+            self._ok = False
+            return None

+ 17 - 0
spoolbuddy/daemon/systemd/spoolbuddy.service

@@ -0,0 +1,17 @@
+[Unit]
+Description=SpoolBuddy Daemon
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=pi
+Environment=SPOOLBUDDY_CONFIG=/etc/spoolbuddy/config.yaml
+ExecStart=/usr/bin/python3 -m spoolbuddy.daemon.main
+Restart=always
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+[Install]
+WantedBy=multi-user.target

+ 41 - 0
spoolbuddy/daemon/tag_parser.py

@@ -0,0 +1,41 @@
+"""Parse Bambu Lab MIFARE Classic tag data blocks into structured metadata."""
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+# Bambu tag block layout (MIFARE Classic 1K):
+# Block 1: material type (bytes 0-7), color info (bytes 8-15)
+# Block 2: temperatures, weights
+# Block 4-5: tray UUID (32 hex chars across 2 blocks)
+
+
+def parse_bambu_blocks(blocks: dict[int, bytes]) -> dict:
+    """Parse raw Bambu MIFARE Classic blocks into metadata dict.
+
+    Args:
+        blocks: Dict mapping block number -> 16 bytes
+
+    Returns:
+        Dict with tray_uuid, material_type, color, etc.
+    """
+    result = {}
+
+    # Extract tray UUID from blocks 4+5
+    if 4 in blocks and 5 in blocks:
+        uuid_raw = blocks[4] + blocks[5]
+        result["tray_uuid"] = uuid_raw[:16].hex().upper()
+
+    # Extract material info from block 1
+    if 1 in blocks:
+        data = blocks[1]
+        # Material type is typically in the first few bytes
+        material_bytes = data[:8]
+        result["material_raw"] = material_bytes.hex().upper()
+
+    # Extract block 2 data (temperatures, weights)
+    if 2 in blocks:
+        data = blocks[2]
+        result["block2_raw"] = data.hex().upper()
+
+    return result

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B00gz1lY.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DlQCzTdY.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DmSskpar.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DmRt97yN.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DlQCzTdY.css">
+    <script type="module" crossorigin src="/assets/index-DmSskpar.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-B00gz1lY.css">
   </head>
   <body>
     <div id="root"></div>

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