Browse Source

[Feature] Spoolbuddy Fixes and Improvements (#787)

[Feature] Spoolbuddy Fixes and Improvements (#787)
Keybored 2 months ago
parent
commit
6e648804fc

+ 31 - 8
backend/app/api/routes/inventory.py

@@ -32,6 +32,7 @@ from backend.app.schemas.spool import (
 )
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
 from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
+from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -1137,6 +1138,22 @@ class LinkTagRequest(BaseModel):
     data_origin: str | None = "nfc_link"
     data_origin: str | None = "nfc_link"
 
 
 
 
+def _validate_tag_input(
+    raw_value: str | None, normalized_value: str | None, field_name: str, exact_len: int | None = None
+) -> None:
+    if raw_value is None:
+        return
+    raw = str(raw_value).strip()
+    if not raw:
+        return
+    if normalized_value is None:
+        raise HTTPException(422, f"{field_name} must contain hexadecimal characters")
+    if len(normalized_value) % 2 != 0:
+        raise HTTPException(422, f"{field_name} must have an even number of hex characters")
+    if exact_len is not None and len(normalized_value) != exact_len:
+        raise HTTPException(422, f"{field_name} must be exactly {exact_len} hex characters")
+
+
 @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
 @router.patch("/spools/{spool_id}/link-tag", response_model=SpoolResponse)
 async def link_tag_to_spool(
 async def link_tag_to_spool(
     spool_id: int,
     spool_id: int,
@@ -1152,11 +1169,17 @@ async def link_tag_to_spool(
     if spool.archived_at:
     if spool.archived_at:
         raise HTTPException(400, "Cannot link tag to archived spool")
         raise HTTPException(400, "Cannot link tag to archived spool")
 
 
+    normalized_tag_uid = (normalize_tag_uid(data.tag_uid) or None) if data.tag_uid is not None else None
+    normalized_tray_uuid = (normalize_tray_uuid(data.tray_uuid) or None) if data.tray_uuid is not None else None
+
+    _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
+    _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
+
     # Check for conflicts: tag already linked to another active spool
     # Check for conflicts: tag already linked to another active spool
-    if data.tag_uid:
+    if normalized_tag_uid:
         conflict = await db.execute(
         conflict = await db.execute(
             select(Spool).where(
             select(Spool).where(
-                Spool.tag_uid == data.tag_uid,
+                func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
                 Spool.archived_at.is_(None),
             )
             )
@@ -1166,7 +1189,7 @@ async def link_tag_to_spool(
         # Auto-clear from archived spools (tag recycling)
         # Auto-clear from archived spools (tag recycling)
         archived_with_tag = await db.execute(
         archived_with_tag = await db.execute(
             select(Spool).where(
             select(Spool).where(
-                Spool.tag_uid == data.tag_uid,
+                func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_not(None),
                 Spool.archived_at.is_not(None),
             )
             )
@@ -1174,10 +1197,10 @@ async def link_tag_to_spool(
         for old_spool in archived_with_tag.scalars().all():
         for old_spool in archived_with_tag.scalars().all():
             old_spool.tag_uid = None
             old_spool.tag_uid = None
 
 
-    if data.tray_uuid:
+    if normalized_tray_uuid:
         conflict = await db.execute(
         conflict = await db.execute(
             select(Spool).where(
             select(Spool).where(
-                Spool.tray_uuid == data.tray_uuid,
+                func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
                 Spool.archived_at.is_(None),
             )
             )
@@ -1186,7 +1209,7 @@ async def link_tag_to_spool(
             raise HTTPException(409, "Tray UUID already linked to another active spool")
             raise HTTPException(409, "Tray UUID already linked to another active spool")
         archived_with_uuid = await db.execute(
         archived_with_uuid = await db.execute(
             select(Spool).where(
             select(Spool).where(
-                Spool.tray_uuid == data.tray_uuid,
+                func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 Spool.id != spool_id,
                 Spool.archived_at.is_not(None),
                 Spool.archived_at.is_not(None),
             )
             )
@@ -1195,9 +1218,9 @@ async def link_tag_to_spool(
             old_spool.tray_uuid = None
             old_spool.tray_uuid = None
 
 
     if data.tag_uid is not None:
     if data.tag_uid is not None:
-        spool.tag_uid = data.tag_uid
+        spool.tag_uid = normalized_tag_uid
     if data.tray_uuid is not None:
     if data.tray_uuid is not None:
-        spool.tray_uuid = data.tray_uuid
+        spool.tray_uuid = normalized_tray_uuid
     if data.tag_type is not None:
     if data.tag_type is not None:
         spool.tag_type = data.tag_type
         spool.tag_type = data.tag_type
     if data.data_origin is not None:
     if data.data_origin is not None:

+ 221 - 4
backend/app/api/routes/spoolbuddy.py

@@ -1,8 +1,11 @@
 """SpoolBuddy device management API routes."""
 """SpoolBuddy device management API routes."""
 
 
 import asyncio
 import asyncio
+import json
 import logging
 import logging
+import time
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
+from urllib.parse import urlparse
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
@@ -18,12 +21,15 @@ from backend.app.schemas.spoolbuddy import (
     CalibrationResponse,
     CalibrationResponse,
     DeviceRegisterRequest,
     DeviceRegisterRequest,
     DeviceResponse,
     DeviceResponse,
+    DiagnosticResultRequest,
     DisplaySettingsRequest,
     DisplaySettingsRequest,
     HeartbeatRequest,
     HeartbeatRequest,
     HeartbeatResponse,
     HeartbeatResponse,
     ScaleReadingRequest,
     ScaleReadingRequest,
     SetCalibrationFactorRequest,
     SetCalibrationFactorRequest,
     SetTareRequest,
     SetTareRequest,
+    SystemCommandResultRequest,
+    SystemConfigRequest,
     TagRemovedRequest,
     TagRemovedRequest,
     TagScannedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
     UpdateSpoolWeightRequest,
@@ -37,6 +43,9 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
 router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
 
 
 OFFLINE_THRESHOLD_SECONDS = 30
 OFFLINE_THRESHOLD_SECONDS = 30
+ONLINE_BROADCAST_INTERVAL_SECONDS = 10
+_spoolbuddy_online_last_broadcast: dict[str, float] = {}
+_diagnostic_results: dict[tuple[str, str], dict] = {}
 
 
 
 
 def _is_online(device: SpoolBuddyDevice) -> bool:
 def _is_online(device: SpoolBuddyDevice) -> bool:
@@ -60,6 +69,7 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         calibration_factor=device.calibration_factor,
         calibration_factor=device.calibration_factor,
         nfc_reader_type=device.nfc_reader_type,
         nfc_reader_type=device.nfc_reader_type,
         nfc_connection=device.nfc_connection,
         nfc_connection=device.nfc_connection,
+        backend_url=device.backend_url,
         display_brightness=device.display_brightness,
         display_brightness=device.display_brightness,
         display_blank_timeout=device.display_blank_timeout,
         display_blank_timeout=device.display_blank_timeout,
         has_backlight=device.has_backlight,
         has_backlight=device.has_backlight,
@@ -77,6 +87,19 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
     )
     )
 
 
 
 
+def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
+    if force:
+        _spoolbuddy_online_last_broadcast[device_id] = time.time()
+        return True
+
+    now_ts = time.time()
+    last_ts = _spoolbuddy_online_last_broadcast.get(device_id, 0.0)
+    if now_ts - last_ts >= ONLINE_BROADCAST_INTERVAL_SECONDS:
+        _spoolbuddy_online_last_broadcast[device_id] = now_ts
+        return True
+    return False
+
+
 # --- Device endpoints ---
 # --- Device endpoints ---
 
 
 
 
@@ -99,6 +122,8 @@ async def register_device(
         device.has_scale = req.has_scale
         device.has_scale = req.has_scale
         device.nfc_reader_type = req.nfc_reader_type
         device.nfc_reader_type = req.nfc_reader_type
         device.nfc_connection = req.nfc_connection
         device.nfc_connection = req.nfc_connection
+        if req.backend_url:
+            device.backend_url = req.backend_url
         device.has_backlight = req.has_backlight
         device.has_backlight = req.has_backlight
         device.last_seen = now
         device.last_seen = now
         # Clear stale update status on re-registration (daemon restarted after update)
         # Clear stale update status on re-registration (daemon restarted after update)
@@ -119,6 +144,7 @@ async def register_device(
             nfc_reader_type=req.nfc_reader_type,
             nfc_reader_type=req.nfc_reader_type,
             nfc_connection=req.nfc_connection,
             nfc_connection=req.nfc_connection,
             has_backlight=req.has_backlight,
             has_backlight=req.has_backlight,
+            backend_url=req.backend_url,
             last_seen=now,
             last_seen=now,
         )
         )
         db.add(device)
         db.add(device)
@@ -127,6 +153,7 @@ async def register_device(
     await db.commit()
     await db.commit()
     await db.refresh(device)
     await db.refresh(device)
 
 
+    _spoolbuddy_online_last_broadcast[device.device_id] = time.time()
     await ws_manager.broadcast(
     await ws_manager.broadcast(
         {
         {
             "type": "spoolbuddy_online",
             "type": "spoolbuddy_online",
@@ -187,25 +214,37 @@ async def device_heartbeat(
         device.nfc_reader_type = req.nfc_reader_type
         device.nfc_reader_type = req.nfc_reader_type
     if req.nfc_connection:
     if req.nfc_connection:
         device.nfc_connection = req.nfc_connection
         device.nfc_connection = req.nfc_connection
+    if req.backend_url:
+        device.backend_url = req.backend_url
 
 
     # Return and clear pending command
     # Return and clear pending command
     pending = device.pending_command
     pending = device.pending_command
     pending_write = None
     pending_write = None
+    pending_system = None
     if pending == "write_tag" and device.pending_write_payload:
     if pending == "write_tag" and device.pending_write_payload:
         # Parse the stored JSON payload to include in response
         # Parse the stored JSON payload to include in response
-        import json
-
         try:
         try:
             pending_write = json.loads(device.pending_write_payload)
             pending_write = json.loads(device.pending_write_payload)
         except (json.JSONDecodeError, TypeError):
         except (json.JSONDecodeError, TypeError):
             pending_write = None
             pending_write = None
         # Don't clear write_tag command — it gets cleared by write-result
         # Don't clear write_tag command — it gets cleared by write-result
+    elif pending == "apply_system_config" and device.pending_system_payload:
+        try:
+            pending_system = json.loads(device.pending_system_payload)
+        except (json.JSONDecodeError, TypeError):
+            pending_system = None
+        # Don't clear config command — it gets cleared by daemon command-result callback
+    elif pending and pending.startswith("run_") and pending.endswith("_diag"):
+        # Don't clear diagnostic commands — they get cleared by the device reporting results
+        pass
     else:
     else:
         device.pending_command = None
         device.pending_command = None
 
 
     await db.commit()
     await db.commit()
 
 
-    if was_offline:
+    # Emit online presence on offline->online transitions immediately, and
+    # periodically while online so newly connected UIs can bootstrap state.
+    if _should_broadcast_online(device.device_id, force=was_offline):
         await ws_manager.broadcast(
         await ws_manager.broadcast(
             {
             {
                 "type": "spoolbuddy_online",
                 "type": "spoolbuddy_online",
@@ -213,10 +252,13 @@ async def device_heartbeat(
                 "hostname": device.hostname,
                 "hostname": device.hostname,
             }
             }
         )
         )
+    if was_offline:
+        logger.info("SpoolBuddy device back online: %s", device.device_id)
 
 
     return HeartbeatResponse(
     return HeartbeatResponse(
         pending_command=pending,
         pending_command=pending,
         pending_write_payload=pending_write,
         pending_write_payload=pending_write,
+        pending_system_payload=pending_system,
         tare_offset=device.tare_offset,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
         calibration_factor=device.calibration_factor,
         display_brightness=device.display_brightness,
         display_brightness=device.display_brightness,
@@ -266,7 +308,15 @@ async def nfc_tag_scanned(
                 "tag_type": req.tag_type,
                 "tag_type": req.tag_type,
             }
             }
         )
         )
-        logger.info("SpoolBuddy unknown tag: %s", req.tag_uid)
+        logger.info(
+            "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
+            req.tag_uid,
+            len(req.tag_uid or ""),
+            req.tray_uuid,
+            len(req.tray_uuid or ""),
+            req.tag_type,
+            req.sak,
+        )
 
 
     return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
     return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
 
 
@@ -582,6 +632,173 @@ async def update_display_settings(
     return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
     return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
 
 
 
 
+@router.post("/devices/{device_id}/system/config")
+async def queue_system_config_update(
+    device_id: str,
+    req: SystemConfigRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Queue update of SpoolBuddy .env config on the 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")
+
+    parsed = urlparse(req.backend_url.strip())
+    if parsed.scheme not in ("http", "https") or not parsed.netloc:
+        raise HTTPException(
+            status_code=400,
+            detail="backend_url must be a full URL with scheme, e.g. http://192.168.1.100:5000 or http://bambuddy.local",
+        )
+
+    payload = {
+        "backend_url": req.backend_url.strip(),
+    }
+    if req.api_key is not None and req.api_key.strip():
+        payload["api_key"] = req.api_key.strip()
+
+    device.pending_system_payload = json.dumps(payload)
+    device.pending_command = "apply_system_config"
+    await db.commit()
+
+    logger.info("Queued system config update for device %s", device_id)
+    return {"status": "queued", "message": "System config update queued"}
+
+
+@router.post("/devices/{device_id}/system/command-result")
+async def system_command_result(
+    device_id: str,
+    req: SystemCommandResultRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Receive completion status for queued system command from daemon."""
+    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")
+
+    if not device.pending_command:
+        logger.info("System command result from %s with no pending command: %s", device_id, req.command)
+        return {"status": "ok", "message": "No pending command"}
+
+    if req.command != device.pending_command:
+        raise HTTPException(
+            status_code=409,
+            detail=f"Command mismatch: pending '{device.pending_command}', got '{req.command}'",
+        )
+
+    if req.command == "apply_system_config":
+        device.pending_system_payload = None
+    device.pending_command = None
+    await db.commit()
+
+    logger.info(
+        "System command result from %s: %s success=%s message=%s",
+        device_id,
+        req.command,
+        req.success,
+        req.message,
+    )
+    return {"status": "ok"}
+
+
+# --- Diagnostics ---
+
+
+@router.post("/diagnostics/{device_id}/run")
+async def queue_diagnostic(
+    device_id: str,
+    diagnostic: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Queue a hardware diagnostic to run on the SpoolBuddy device.
+
+    Args:
+        device_id: The device ID
+        diagnostic: 'scale' or 'nfc' to select which diagnostic to run
+
+    Returns:
+        Status message indicating diagnostic was queued
+    """
+    if diagnostic not in ("scale", "nfc", "read_tag"):
+        raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
+
+    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 = f"run_{diagnostic}_diag"
+    _diagnostic_results.pop((device_id, diagnostic), None)
+    await db.commit()
+
+    logger.info("Diagnostic queued for device %s: %s", device_id, diagnostic)
+    return {"status": "queued", "diagnostic": diagnostic, "message": f"Diagnostic '{diagnostic}' queued for device"}
+
+
+@router.get("/diagnostics/{device_id}/result")
+async def get_diagnostic_result(
+    device_id: str,
+    diagnostic: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Get the latest diagnostic result for a device.
+
+    Args:
+        device_id: The device ID
+        diagnostic: 'scale' or 'nfc'
+
+    Returns:
+        Diagnostic result or 404 if not found
+    """
+    if diagnostic not in ("scale", "nfc", "read_tag"):
+        raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
+
+    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")
+
+    diag_result = _diagnostic_results.get((device_id, diagnostic))
+    if not diag_result:
+        raise HTTPException(status_code=404, detail=f"No {diagnostic} diagnostic results available yet")
+    return diag_result
+
+
+@router.post("/diagnostics/{device_id}/result")
+async def report_diagnostic_result(
+    device_id: str,
+    req: DiagnosticResultRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Report diagnostic result from SpoolBuddy 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")
+
+    if req.diagnostic not in ("nfc", "scale", "read_tag"):
+        raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
+
+    _diagnostic_results[(device_id, req.diagnostic)] = {
+        "diagnostic": req.diagnostic,
+        "success": req.success,
+        "output": req.output,
+        "exit_code": req.exit_code,
+    }
+
+    device.pending_command = None
+    await db.commit()
+
+    logger.info("Diagnostic result received for device %s: %s (success=%s)", device_id, req.diagnostic, req.success)
+    return {"status": "ok", "message": "Diagnostic result recorded"}
+
+
 # --- Update check ---
 # --- Update check ---
 
 
 
 

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

@@ -1414,6 +1414,16 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Persist SpoolBuddy backend URL and queued system payload
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN backend_url VARCHAR(255)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN pending_system_payload TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
     # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     try:
     try:

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

@@ -22,6 +22,7 @@ class SpoolBuddyDevice(Base):
     calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
     calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
     nfc_reader_type: Mapped[str | None] = mapped_column(String(20))
     nfc_reader_type: Mapped[str | None] = mapped_column(String(20))
     nfc_connection: Mapped[str | None] = mapped_column(String(20))
     nfc_connection: Mapped[str | None] = mapped_column(String(20))
+    backend_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
     display_brightness: Mapped[int] = mapped_column(Integer, default=100)
     display_brightness: Mapped[int] = mapped_column(Integer, default=100)
     display_blank_timeout: Mapped[int] = mapped_column(Integer, default=0)
     display_blank_timeout: Mapped[int] = mapped_column(Integer, default=0)
     has_backlight: Mapped[bool] = mapped_column(Boolean, default=False)
     has_backlight: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -31,6 +32,7 @@ class SpoolBuddyDevice(Base):
     pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     pending_write_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     update_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
     update_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
     update_message: Mapped[str | None] = mapped_column(String(255), nullable=True)
     update_message: Mapped[str | None] = mapped_column(String(255), nullable=True)
+    pending_system_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)

+ 25 - 4
backend/app/schemas/spoolbuddy.py

@@ -16,6 +16,7 @@ class DeviceRegisterRequest(BaseModel):
     calibration_factor: float = 1.0
     calibration_factor: float = 1.0
     nfc_reader_type: str | None = None
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
     has_backlight: bool = False
     has_backlight: bool = False
 
 
 
 
@@ -31,6 +32,7 @@ class DeviceResponse(BaseModel):
     calibration_factor: float
     calibration_factor: float
     nfc_reader_type: str | None = None
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
     display_brightness: int = 100
     display_brightness: int = 100
     display_blank_timeout: int = 0
     display_blank_timeout: int = 0
     has_backlight: bool = False
     has_backlight: bool = False
@@ -59,11 +61,13 @@ class HeartbeatRequest(BaseModel):
     ip_address: str | None = None
     ip_address: str | None = None
     nfc_reader_type: str | None = None
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
 
 
 
 
 class HeartbeatResponse(BaseModel):
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
     pending_command: str | None = None
     pending_write_payload: dict | None = None
     pending_write_payload: dict | None = None
+    pending_system_payload: dict | None = None
     tare_offset: int
     tare_offset: int
     calibration_factor: float
     calibration_factor: float
     display_brightness: int = 100
     display_brightness: int = 100
@@ -105,10 +109,6 @@ class UpdateSpoolWeightRequest(BaseModel):
 # --- Calibration schemas ---
 # --- Calibration schemas ---
 
 
 
 
-class TareRequest(BaseModel):
-    pass
-
-
 class SetTareRequest(BaseModel):
 class SetTareRequest(BaseModel):
     tare_offset: int
     tare_offset: int
 
 
@@ -143,3 +143,24 @@ class WriteTagResultRequest(BaseModel):
 class DisplaySettingsRequest(BaseModel):
 class DisplaySettingsRequest(BaseModel):
     brightness: int = Field(ge=0, le=100)
     brightness: int = Field(ge=0, le=100)
     blank_timeout: int = Field(ge=0)
     blank_timeout: int = Field(ge=0)
+
+
+class SystemConfigRequest(BaseModel):
+    backend_url: str = Field(..., min_length=1, max_length=255)
+    api_key: str | None = Field(default=None, max_length=255)
+
+
+class SystemCommandResultRequest(BaseModel):
+    command: str
+    success: bool
+    message: str | None = None
+
+
+# --- Diagnostics schemas ---
+
+
+class DiagnosticResultRequest(BaseModel):
+    diagnostic: str  # 'nfc', 'scale', or 'read_tag'
+    success: bool
+    output: str
+    exit_code: int

+ 80 - 10
backend/app/services/spool_tag_matcher.py

@@ -2,12 +2,16 @@
 
 
 import logging
 import logging
 
 
-from sqlalchemy import func, select
+from sqlalchemy import func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.utils.tag_normalization import (
+    normalize_tag_uid as _normalize_tag_uid,
+    normalize_tray_uuid as _normalize_tray_uuid,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -18,14 +22,17 @@ ZERO_TRAY_UUID = "00000000000000000000000000000000"
 
 
 def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
 def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
     """Check if a tag/UUID pair contains a non-zero, non-empty value."""
     """Check if a tag/UUID pair contains a non-zero, non-empty value."""
-    uid_valid = bool(tag_uid) and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid)
-    uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
+    uid = _normalize_tag_uid(tag_uid)
+    uuid = _normalize_tray_uuid(tray_uuid)
+    uid_valid = bool(uid) and uid != ZERO_TAG_UID and uid != "0" * len(uid)
+    uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid)
     return uid_valid or uuid_valid
     return uid_valid or uuid_valid
 
 
 
 
 def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
 def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
     """Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset)."""
     """Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset)."""
-    uuid_valid = bool(tray_uuid) and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid)
+    uuid = _normalize_tray_uuid(tray_uuid)
+    uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid)
     has_preset = bool(tray_info_idx)
     has_preset = bool(tray_info_idx)
     return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)
     return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)
 
 
@@ -43,8 +50,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     tray_sub_brands = tray_data.get("tray_sub_brands", "")  # "PLA Basic"
     tray_sub_brands = tray_data.get("tray_sub_brands", "")  # "PLA Basic"
     tray_color = tray_data.get("tray_color", "FFFFFFFF")  # RRGGBBAA
     tray_color = tray_data.get("tray_color", "FFFFFFFF")  # RRGGBBAA
     tray_id_name = tray_data.get("tray_id_name", "")  # Color name e.g. "Jade White"
     tray_id_name = tray_data.get("tray_id_name", "")  # Color name e.g. "Jade White"
-    tag_uid = tray_data.get("tag_uid", "")
-    tray_uuid = tray_data.get("tray_uuid", "")
+    tag_uid = _normalize_tag_uid(tray_data.get("tag_uid", ""))
+    tray_uuid = _normalize_tray_uuid(tray_data.get("tray_uuid", ""))
     tray_info_idx = tray_data.get("tray_info_idx", "")
     tray_info_idx = tray_data.get("tray_info_idx", "")
     nozzle_min = tray_data.get("nozzle_temp_min", 0)
     nozzle_min = tray_data.get("nozzle_temp_min", 0)
     nozzle_max = tray_data.get("nozzle_temp_max", 0)
     nozzle_max = tray_data.get("nozzle_temp_max", 0)
@@ -268,12 +275,15 @@ async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Sp
 
 
     Prefers tray_uuid match over tag_uid (more reliable).
     Prefers tray_uuid match over tag_uid (more reliable).
     """
     """
+    tray_uuid_norm = _normalize_tray_uuid(tray_uuid)
+    tag_uid_norm = _normalize_tag_uid(tag_uid)
+
     # Try tray_uuid first (Bambu Lab spools — more reliable)
     # Try tray_uuid first (Bambu Lab spools — more reliable)
-    if tray_uuid and tray_uuid != ZERO_TRAY_UUID and tray_uuid != "0" * len(tray_uuid):
+    if tray_uuid_norm and tray_uuid_norm != ZERO_TRAY_UUID and tray_uuid_norm != "0" * len(tray_uuid_norm):
         result = await db.execute(
         result = await db.execute(
             select(Spool)
             select(Spool)
             .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
             .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
-            .where(Spool.tray_uuid == tray_uuid, Spool.archived_at.is_(None))
+            .where(func.upper(Spool.tray_uuid) == tray_uuid_norm, Spool.archived_at.is_(None))
             .limit(1)
             .limit(1)
         )
         )
         spool = result.scalar_one_or_none()
         spool = result.scalar_one_or_none()
@@ -281,17 +291,77 @@ async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Sp
             return spool
             return spool
 
 
     # Fall back to tag_uid
     # Fall back to tag_uid
-    if tag_uid and tag_uid != ZERO_TAG_UID and tag_uid != "0" * len(tag_uid):
+    if tag_uid_norm and tag_uid_norm != ZERO_TAG_UID and tag_uid_norm != "0" * len(tag_uid_norm):
         result = await db.execute(
         result = await db.execute(
             select(Spool)
             select(Spool)
             .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
             .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
-            .where(Spool.tag_uid == tag_uid, Spool.archived_at.is_(None))
+            .where(func.upper(Spool.tag_uid) == tag_uid_norm, Spool.archived_at.is_(None))
             .limit(1)
             .limit(1)
         )
         )
         spool = result.scalar_one_or_none()
         spool = result.scalar_one_or_none()
         if spool:
         if spool:
             return spool
             return spool
 
 
+        # Compatibility fallback: some readers report 4-byte UID (8 hex) while
+        # stored values may contain longer forms. Prefer suffix match only.
+        if len(tag_uid_norm) >= 8:
+            suffix8 = tag_uid_norm[-8:]
+            short_uid_body = tag_uid_norm[1:] if len(tag_uid_norm) == 8 else ""
+
+            # Build LIKE patterns for candidates search
+            like_patterns = [
+                func.upper(Spool.tag_uid).like(f"%{tag_uid_norm}"),
+                func.upper(Spool.tag_uid).like(f"%{suffix8}"),
+            ]
+            if short_uid_body:
+                like_patterns.append(func.upper(Spool.tag_uid).like(f"%{short_uid_body}%"))
+
+            candidates = await db.execute(
+                select(Spool)
+                .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
+                .where(
+                    Spool.tag_uid.is_not(None),
+                    Spool.archived_at.is_(None),
+                    or_(*like_patterns),
+                )
+                .limit(100)
+            )
+            for candidate in candidates.scalars().all():
+                candidate_uid = _normalize_tag_uid(candidate.tag_uid)
+                if not candidate_uid:
+                    continue
+                if candidate_uid == tag_uid_norm:
+                    return candidate
+                if len(candidate_uid) > len(tag_uid_norm) and candidate_uid.endswith(tag_uid_norm):
+                    return candidate
+                if len(tag_uid_norm) > len(candidate_uid) and tag_uid_norm.endswith(candidate_uid):
+                    return candidate
+                # Backward-compatible matching: allow first-character mismatch
+                # when remaining characters match. This handles cases where the same
+                # physical tag reports different first bytes across different readers
+                # (e.g., one reader reports "A45012F", another reports "B45012F").
+                if len(tag_uid_norm) == len(candidate_uid) and len(tag_uid_norm) > 1:
+                    # Same length: check if all chars except the first match
+                    if candidate_uid[1:] == tag_uid_norm[1:]:
+                        logger.warning(
+                            "Matched spool %d via first-char variance: stored=%s → scanned=%s",
+                            candidate.id,
+                            candidate_uid,
+                            tag_uid_norm,
+                        )
+                        return candidate
+                # Short UID (8 chars) matching: allow first-character mismatch
+                # within the first 8 bytes when remaining 7 chars match.
+                if len(tag_uid_norm) == 8 and len(candidate_uid) >= 8:
+                    if candidate_uid[:8][1:] == tag_uid_norm[1:]:
+                        logger.warning(
+                            "Matched spool %d via short UID variance: stored=%s → scanned=%s",
+                            candidate.id,
+                            candidate_uid,
+                            tag_uid_norm,
+                        )
+                        return candidate
+
     return None
     return None
 
 
 
 

+ 24 - 0
backend/app/utils/tag_normalization.py

@@ -0,0 +1,24 @@
+"""Shared helpers for normalizing RFID tag and tray identifiers."""
+
+
+def normalize_hex(value: str | None) -> str:
+    if not value:
+        return ""
+    hex_chars = "".join(ch for ch in str(value).strip() if ch in "0123456789abcdefABCDEF")
+    return hex_chars.upper()
+
+
+def normalize_tag_uid(value: str | None) -> str:
+    uid = normalize_hex(value)
+    # DB column is VARCHAR(16), so keep the least-significant bytes if longer.
+    if len(uid) > 16:
+        uid = uid[-16:]
+    return uid
+
+
+def normalize_tray_uuid(value: str | None) -> str:
+    uuid = normalize_hex(value)
+    # DB column is VARCHAR(32). Keep canonical 32-char UUID when possible.
+    if len(uuid) >= 32:
+        uuid = uuid[:32]
+    return uuid

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

@@ -7,6 +7,7 @@ import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.api.routes import spoolbuddy as spoolbuddy_routes
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
 
 
@@ -154,6 +155,7 @@ class TestDeviceEndpoints:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
     async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
         device = await device_factory(device_id="sb-hb")
         device = await device_factory(device_id="sb-hb")
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
 
 
         with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
         with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
             mock_ws.broadcast = AsyncMock()
             mock_ws.broadcast = AsyncMock()
@@ -166,6 +168,10 @@ class TestDeviceEndpoints:
         data = resp.json()
         data = resp.json()
         assert data["tare_offset"] == device.tare_offset
         assert data["tare_offset"] == device.tare_offset
         assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
         assert data["calibration_factor"] == pytest.approx(device.calibration_factor)
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-hb"
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
@@ -208,6 +214,7 @@ class TestDeviceEndpoints:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
     async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
         # Create device with last_seen far in the past (offline)
         # Create device with last_seen far in the past (offline)
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
         await device_factory(
         await device_factory(
             device_id="sb-offline",
             device_id="sb-offline",
             last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
             last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
@@ -227,6 +234,55 @@ class TestDeviceEndpoints:
         assert msg["type"] == "spoolbuddy_online"
         assert msg["type"] == "spoolbuddy_online"
         assert msg["device_id"] == "sb-offline"
         assert msg["device_id"] == "sb-offline"
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_broadcasts_online_when_already_online(self, async_client: AsyncClient, device_factory):
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
+        await device_factory(
+            device_id="sb-already-online",
+            last_seen=datetime.now(timezone.utc),
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/devices/sb-already-online/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 42},
+            )
+
+        assert resp.status_code == 200
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-already-online"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_heartbeat_online_broadcast_is_throttled(self, async_client: AsyncClient, device_factory):
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
+        await device_factory(
+            device_id="sb-throttle",
+            last_seen=datetime.now(timezone.utc),
+        )
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp1 = await async_client.post(
+                f"{API}/devices/sb-throttle/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 10},
+            )
+            resp2 = await async_client.post(
+                f"{API}/devices/sb-throttle/heartbeat",
+                json={"nfc_ok": True, "scale_ok": True, "uptime_s": 11},
+            )
+
+        assert resp1.status_code == 200
+        assert resp2.status_code == 200
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_online"
+        assert msg["device_id"] == "sb-throttle"
+
 
 
 # ============================================================================
 # ============================================================================
 # NFC endpoints
 # NFC endpoints

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

@@ -198,6 +198,67 @@ async def test_get_spool_by_tag_returns_none_for_zeros(db_session):
     assert found is None
     assert found is None
 
 
 
 
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_first_char_variance_same_length(db_session):
+    """Match spool when scanned tag differs only in first character.
+
+    Handles case where same physical tag reports different first bytes
+    across different readers (e.g., "A45012F" stored, "B45012F" scanned).
+    Both tags have same length and differ only in first char.
+    """
+    spool = Spool(
+        material="PLA",
+        tag_uid="A4501234CCDDEE88",  # First tag variant
+        label_weight=1000,
+        core_weight=250,
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.commit()
+
+    # Scan with different first character — should still match
+    found = await get_spool_by_tag(db_session, "B4501234CCDDEE88", "")
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+@pytest.mark.skip(reason="Pending reliable short-UID LIKE coverage across fixtures")
+async def test_get_spool_by_tag_first_char_variance_short_uid(db_session):
+    """Match spool when 8-char scanned tag differs only in first character.
+
+    Handles short UID (8 char) from 4-byte readers with first-char variance.
+    """
+    pass
+
+
+@pytest.mark.asyncio
+@pytest.mark.skip(reason="Pending reliable exact-vs-variance short-UID fixture setup")
+async def test_get_spool_by_tag_short_uid_exact_match_preferred(db_session):
+    """Prefer exact match over first-char variance match."""
+    pass
+
+
+@pytest.mark.asyncio
+async def test_get_spool_by_tag_no_false_positive_different_suffix(db_session):
+    """Don't match tags with different suffixes just because first char varies."""
+    spool = Spool(
+        material="PLA",
+        tag_uid="AABBCCDD11223344",
+        label_weight=1000,
+        core_weight=250,
+    )
+    spool.k_profiles = []
+    spool.assignments = []
+    db_session.add(spool)
+    await db_session.commit()
+
+    # Scan with different suffix (only first char is same) — should NOT match
+    found = await get_spool_by_tag(db_session, "AABBCCDD11223355", "")
+    assert found is None, "Should not match when suffix differs"
+
+
 # -- auto_assign_spool (SpoolAssignment creation) ---------------------------
 # -- auto_assign_spool (SpoolAssignment creation) ---------------------------
 
 
 
 

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

@@ -4996,6 +4996,7 @@ export interface SpoolBuddyDevice {
   device_id: string;
   device_id: string;
   hostname: string;
   hostname: string;
   ip_address: string;
   ip_address: string;
+  backend_url?: string | null;
   firmware_version: string | null;
   firmware_version: string | null;
   has_nfc: boolean;
   has_nfc: boolean;
   has_scale: boolean;
   has_scale: boolean;
@@ -5055,6 +5056,12 @@ export const spoolbuddyApi = {
       body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
       body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
     }),
     }),
 
 
+  updateSystemConfig: (deviceId: string, backendUrl: string, apiKey?: string) =>
+    request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/system/config`, {
+      method: 'POST',
+      body: JSON.stringify({ backend_url: backendUrl, ...(apiKey ? { api_key: apiKey } : {}) }),
+    }),
+
   checkDaemonUpdate: (deviceId: string) =>
   checkDaemonUpdate: (deviceId: string) =>
     request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check`),
     request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check`),
 
 
@@ -5078,6 +5085,18 @@ export const spoolbuddyApi = {
       method: 'POST',
       method: 'POST',
       body: '{}',
       body: '{}',
     }),
     }),
+
+  queueDiagnostics: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
+    request<{ status: string; diagnostic: string; message: string }>(
+      `/spoolbuddy/diagnostics/${deviceId}/run?diagnostic=${type}`,
+      { method: 'POST', body: '{}' }
+    ),
+
+  getDiagnosticResult: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
+    request<{ diagnostic: string; success: boolean; output: string; exit_code: number }>(
+      `/spoolbuddy/diagnostics/${deviceId}/result?diagnostic=${type}`,
+      { method: 'GET' }
+    ),
 };
 };
 
 
 export interface BugReportRequest {
 export interface BugReportRequest {

+ 11 - 1
frontend/src/components/SpoolFormModal.tsx

@@ -24,9 +24,17 @@ interface SpoolFormModalProps {
   spool?: InventorySpool | null;
   spool?: InventorySpool | null;
   printersWithCalibrations?: PrinterWithCalibrations[];
   printersWithCalibrations?: PrinterWithCalibrations[];
   currencySymbol: string;
   currencySymbol: string;
+  onSpoolsCreated?: (spools: InventorySpool[]) => void;
 }
 }
 
 
-export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibrations = [], currencySymbol }: SpoolFormModalProps) {
+export function SpoolFormModal({
+  isOpen,
+  onClose,
+  spool,
+  printersWithCalibrations = [],
+  currencySymbol,
+  onSpoolsCreated,
+}: SpoolFormModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
@@ -317,6 +325,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         await saveKProfiles(newSpool.id);
         await saveKProfiles(newSpool.id);
       }
       }
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       showToast(t('inventory.spoolCreated'), 'success');
       showToast(t('inventory.spoolCreated'), 'success');
       onClose();
       onClose();
     },
     },
@@ -335,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         }
         }
       }
       }
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      if (onSpoolsCreated) onSpoolsCreated(newSpools);
       showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
       showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
       onClose();
       onClose();
     },
     },

+ 172 - 0
frontend/src/components/spoolbuddy/DiagnosticModal.tsx

@@ -0,0 +1,172 @@
+import { useState, useEffect, useCallback } from 'react';
+import { X, Play, RotateCw } from 'lucide-react';
+import { spoolbuddyApi } from '../../api/client';
+import { useTranslation } from 'react-i18next';
+
+interface DiagnosticModalProps {
+  type: 'scale' | 'nfc' | 'read_tag';
+  deviceId: string;
+  onClose: () => void;
+}
+
+export function DiagnosticModal({ type, deviceId, onClose }: DiagnosticModalProps) {
+  const { t } = useTranslation();
+  const [isRunning, setIsRunning] = useState(false);
+  const [output, setOutput] = useState<string>('');
+  const [error, setError] = useState<string>('');
+  const [hasRun, setHasRun] = useState(false);
+
+  // Close on Escape
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !isRunning) {
+        onClose();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [isRunning, onClose]);
+
+  const runDiagnostic = useCallback(async () => {
+    setIsRunning(true);
+    setOutput('');
+    setError('');
+    setHasRun(true);
+
+    try {
+      // Step 1: Queue the diagnostic on the device
+      setOutput(t('spoolbuddy.diagnostic.queuing', 'Queuing diagnostic on device...\n'));
+      await spoolbuddyApi.queueDiagnostics(deviceId, type);
+
+      // Step 2: Poll for results with timeout
+      let result = null;
+      const maxRetries = 60; // 30s timeout with 500ms polling
+      let retryCount = 0;
+
+      while (retryCount < maxRetries && !result) {
+        // Wait a bit before polling
+        await new Promise(resolve => setTimeout(resolve, 500));
+
+        try {
+          result = await spoolbuddyApi.getDiagnosticResult(deviceId, type);
+          break;
+        } catch (e) {
+          // Not ready yet, continue polling
+          retryCount++;
+          if (retryCount % 4 === 0) {
+            // Update every 2 seconds (after 4 retries of 500ms)
+            setOutput(prev => prev + '.');
+          }
+        }
+      }
+
+      if (!result) {
+        throw new Error('Diagnostic timed out - device did not report results');
+      }
+
+      setOutput(result.output);
+      if (!result.success) {
+        setError(`Exit code: ${result.exit_code}`);
+      }
+    } catch (e) {
+      setError(e instanceof Error ? e.message : 'Unknown error');
+      setOutput('');
+    } finally {
+      setIsRunning(false);
+    }
+  }, [type, deviceId]);
+
+  const title = type === 'scale'
+    ? t('spoolbuddy.diagnostic.scaleTitle', 'Scale Diagnostic')
+    : type === 'read_tag'
+      ? t('spoolbuddy.diagnostic.readTagTitle', 'Read Tag Diagnostic')
+      : t('spoolbuddy.diagnostic.nfcTitle', 'NFC Reader Diagnostic');
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 animate-fade-in"
+      onClick={onClose}
+    >
+      <div
+        className="bg-zinc-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col animate-slide-up"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex justify-between items-center p-4 border-b border-zinc-700">
+          <h2 className="text-lg font-semibold text-white">{title}</h2>
+          <button
+            onClick={onClose}
+            className="text-zinc-400 hover:text-white transition-colors"
+            aria-label="Close"
+          >
+            <X size={20} />
+          </button>
+        </div>
+
+        <div className="flex-1 overflow-auto p-4 bg-black/50 font-mono text-sm">
+          {isRunning ? (
+            <div className="flex items-center gap-2 text-green-400">
+              <div className="animate-spin w-4 h-4 border-2 border-green-400 border-t-transparent rounded-full" />
+              <span>{t('spoolbuddy.diagnostic.running', 'Running diagnostic on device...')}</span>
+            </div>
+          ) : output ? (
+            <>
+              <div className="text-green-400 whitespace-pre-wrap break-words">
+                {output}
+              </div>
+              {error && (
+                <div className="text-red-400 mt-2">
+                  ❌ {error}
+                </div>
+              )}
+            </>
+          ) : hasRun ? (
+            <div>
+              {error ? (
+                <div className="text-red-400">ERROR: {error}</div>
+              ) : (
+                <span className="text-green-400">{t('spoolbuddy.diagnostic.completed', 'Diagnostic completed successfully.')}</span>
+              )}
+            </div>
+                ) : (
+            <div className="text-zinc-500">
+              {t('spoolbuddy.diagnostic.clickStart', 'Click "Run Diagnostic" to start the hardware diagnostic on')} {deviceId}.
+            </div>
+          )}
+        </div>
+        
+        {/* Footer */}
+        <div className="flex gap-2 p-4 border-t border-zinc-700 bg-zinc-800">
+          <button
+            onClick={runDiagnostic}
+            disabled={isRunning}
+            className="flex-1 flex items-center justify-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold text-white transition-colors"
+          >
+            {isRunning ? (
+              <>
+                <div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
+                {t('spoolbuddy.diagnostic.runningBtn', 'Running...')}
+              </>
+            ) : hasRun ? (
+              <>
+                <RotateCw size={16} />
+                {t('spoolbuddy.diagnostic.runAgain', 'Run Again')}
+              </>
+            ) : (
+              <>
+                <Play size={16} />
+                {t('spoolbuddy.diagnostic.runBtn', 'Run Diagnostic')}
+              </>
+            )}
+          </button>
+          <button
+            onClick={onClose}
+            className="px-4 py-2 rounded bg-zinc-700 hover:bg-zinc-600 text-white font-semibold transition-colors"
+          >
+            {t('spoolbuddy.diagnostic.close', 'Close')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 10 - 5
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
 import { Outlet } from 'react-router-dom';
 import { Outlet } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -37,6 +37,11 @@ export function SpoolBuddyLayout() {
     refetchInterval: 30000,
     refetchInterval: 30000,
   });
   });
   const device = devices[0];
   const device = devices[0];
+  const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);
+  const sbStateForUi = useMemo(
+    () => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),
+    [sbState, effectiveDeviceOnline]
+  );
 
 
   // Sync display settings from device on initial load
   // Sync display settings from device on initial load
   const initializedRef = useRef(false);
   const initializedRef = useRef(false);
@@ -69,14 +74,14 @@ export function SpoolBuddyLayout() {
 
 
   // Update alert based on device state and available updates
   // Update alert based on device state and available updates
   useEffect(() => {
   useEffect(() => {
-    if (!sbState.deviceOnline) {
+    if (!effectiveDeviceOnline) {
       setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
       setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
     } else if (updateCheck?.update_available && updateCheck.latest_version) {
     } else if (updateCheck?.update_available && updateCheck.latest_version) {
       setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
       setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
     } else {
     } else {
       setAlert(null);
       setAlert(null);
     }
     }
-  }, [sbState.deviceOnline, updateCheck]);
+  }, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
 
 
   // Track user activity for screen blank
   // Track user activity for screen blank
   const resetActivity = useCallback(() => {
   const resetActivity = useCallback(() => {
@@ -118,12 +123,12 @@ export function SpoolBuddyLayout() {
         <SpoolBuddyTopBar
         <SpoolBuddyTopBar
           selectedPrinterId={selectedPrinterId}
           selectedPrinterId={selectedPrinterId}
           onPrinterChange={setSelectedPrinterId}
           onPrinterChange={setSelectedPrinterId}
-          deviceOnline={sbState.deviceOnline}
+          deviceOnline={effectiveDeviceOnline}
         />
         />
 
 
         <main className="flex-1 overflow-y-auto">
         <main className="flex-1 overflow-y-auto">
           <Outlet context={{
           <Outlet context={{
-            selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
+            selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
             displayBrightness, setDisplayBrightness,
             displayBrightness, setDisplayBrightness,
             displayBlankTimeout, setDisplayBlankTimeout,
             displayBlankTimeout, setDisplayBlankTimeout,
           }} />
           }} />

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

@@ -4467,6 +4467,19 @@ export default {
       deviceInfo: 'Device Info',
       deviceInfo: 'Device Info',
       hostname: 'Host',
       hostname: 'Host',
       uptime: 'Uptime',
       uptime: 'Uptime',
+      systemConfig: 'Backend & Auth',
+      backendUrl: 'Bambuddy Backend URL',
+      apiToken: 'API Token',
+      apiTokenPlaceholder: 'Enter API token',
+      saveConfig: 'Save Config',
+      systemQueued: 'Config queued.',
+      nfcDiagnostic: 'NFC Diagnostic',
+      scaleDiagnostic: 'Scale Diagnostic',
+      readTagDiagnostic: 'Read Tag Diagnostic',
+      testNfc: 'Test reader',
+      testScale: 'Test accuracy',
+      testReadTag: 'Read tag',
+      systemFieldsRequired: 'Backend URL is required.',
       // Display tab
       // Display tab
       brightness: 'Brightness',
       brightness: 'Brightness',
       saved: 'Saved',
       saved: 'Saved',

+ 0 - 2
frontend/src/pages/SettingsPage.tsx

@@ -1067,8 +1067,6 @@ export function SettingsPage() {
           <span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
           <span className={`w-2 h-2 rounded-full ${cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
         </button>
         </button>
       </div>
       </div>
-
-      {/* General Tab */}
       {activeTab === 'general' && (
       {activeTab === 'general' && (
       <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
       <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
         {/* Left Column - General Settings */}
         {/* Left Column - General Settings */}

+ 31 - 13
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -15,6 +15,20 @@ const SPOOL_COLORS = [
   '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
   '#FBBF24', '#14B8A6', '#EC4899', '#F97316', '#22C55E',
 ];
 ];
 
 
+function normalizeHexTag(value: string | null | undefined): string {
+  if (!value) return '';
+  return value.replace(/[^0-9a-f]/gi, '').toUpperCase();
+}
+
+function tagsEquivalent(a: string | null | undefined, b: string | null | undefined): boolean {
+  const aNorm = normalizeHexTag(a);
+  const bNorm = normalizeHexTag(b);
+  if (!aNorm || !bNorm) return false;
+  if (aNorm === bNorm) return true;
+  // Some readers report shortened UID forms.
+  return aNorm.endsWith(bNorm) || bNorm.endsWith(aNorm);
+}
+
 // --- Idle state with color-cycling spool and NFC waves ---
 // --- Idle state with color-cycling spool and NFC waves ---
 function ColorCyclingSpool() {
 function ColorCyclingSpool() {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -156,9 +170,13 @@ export function SpoolBuddyDashboard() {
 
 
   // Find spool by tag_id in the loaded spools list
   // Find spool by tag_id in the loaded spools list
   const displayedSpool = useMemo(() => {
   const displayedSpool = useMemo(() => {
+    if (sbState.matchedSpool?.id) {
+      const byId = spools.find((s) => s.id === sbState.matchedSpool?.id);
+      if (byId) return byId;
+    }
     if (!displayedTagId) return null;
     if (!displayedTagId) return null;
-    return spools.find((s) => s.tag_uid === displayedTagId) ?? null;
-  }, [displayedTagId, spools]);
+    return spools.find((s) => tagsEquivalent(s.tag_uid, displayedTagId)) ?? null;
+  }, [displayedTagId, sbState.matchedSpool, spools]);
 
 
   // Untagged spools for the Link feature
   // Untagged spools for the Link feature
   const untaggedSpools = useMemo(() => {
   const untaggedSpools = useMemo(() => {
@@ -363,26 +381,26 @@ export function SpoolBuddyDashboard() {
             <div className="flex-1 flex items-center justify-center min-h-0">
             <div className="flex-1 flex items-center justify-center min-h-0">
               {!sbState.deviceOnline ? (
               {!sbState.deviceOnline ? (
                 <DeviceOfflineState />
                 <DeviceOfflineState />
-              ) : displayedSpool && displayedTagId && hiddenTagId !== displayedTagId ? (
+              ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
                 <SpoolInfoCard
                 <SpoolInfoCard
                   spool={{
                   spool={{
-                    id: displayedSpool.id,
+                    id: displayedSpool?.id ?? sbState.matchedSpool!.id,
                     tag_uid: displayedTagId,
                     tag_uid: displayedTagId,
-                    material: displayedSpool.material,
-                    subtype: displayedSpool.subtype,
-                    color_name: displayedSpool.color_name,
-                    rgba: displayedSpool.rgba,
-                    brand: displayedSpool.brand,
-                    label_weight: displayedSpool.label_weight,
-                    core_weight: displayedSpool.core_weight,
-                    weight_used: displayedSpool.weight_used,
+                    material: displayedSpool?.material ?? sbState.matchedSpool!.material,
+                    subtype: displayedSpool?.subtype ?? sbState.matchedSpool!.subtype,
+                    color_name: displayedSpool?.color_name ?? sbState.matchedSpool!.color_name,
+                    rgba: displayedSpool?.rgba ?? sbState.matchedSpool!.rgba,
+                    brand: displayedSpool?.brand ?? sbState.matchedSpool!.brand,
+                    label_weight: displayedSpool?.label_weight ?? sbState.matchedSpool!.label_weight,
+                    core_weight: displayedSpool?.core_weight ?? sbState.matchedSpool!.core_weight,
+                    weight_used: displayedSpool?.weight_used ?? sbState.matchedSpool!.weight_used,
                   }}
                   }}
                   scaleWeight={liveWeight ?? displayedWeight}
                   scaleWeight={liveWeight ?? displayedWeight}
                   onSyncWeight={() => refetchSpools()}
                   onSyncWeight={() => refetchSpools()}
                   onAssignToAms={() => setShowAssignAmsModal(true)}
                   onAssignToAms={() => setShowAssignAmsModal(true)}
                   onClose={handleCloseSpoolCard}
                   onClose={handleCloseSpoolCard}
                 />
                 />
-              ) : displayedTagId && !displayedSpool && hiddenTagId !== displayedTagId ? (
+              ) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (
                 <UnknownTagCard
                 <UnknownTagCard
                   tagUid={displayedTagId}
                   tagUid={displayedTagId}
                   scaleWeight={liveWeight ?? displayedWeight}
                   scaleWeight={liveWeight ?? displayedWeight}

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

@@ -4,6 +4,10 @@ import { useOutletContext } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
 import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
+import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
+import { FileText, Wand2, Zap } from 'lucide-react';
+
+
 function formatUptime(seconds: number): string {
 function formatUptime(seconds: number): string {
   if (seconds < 60) return `${seconds}s`;
   if (seconds < 60) return `${seconds}s`;
   if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
   if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
@@ -36,6 +40,39 @@ const BLANK_OPTIONS = [
 
 
 function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
 function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const [diagnosticOpen, setDiagnosticOpen] = useState<'nfc' | 'scale' | 'read_tag' | null>(null);
+  const [backendUrl, setBackendUrl] = useState('');
+  const [apiToken, setApiToken] = useState('');
+  const [systemBusy, setSystemBusy] = useState(false);
+  const [systemMsg, setSystemMsg] = useState<{ type: 'ok' | 'error'; text: string } | null>(null);
+
+  useEffect(() => {
+    if (!backendUrl && device.backend_url) {
+      setBackendUrl(device.backend_url);
+    }
+  }, [device.backend_url, backendUrl]);
+
+  const saveConfig = async () => {
+    if (!backendUrl.trim()) {
+      setSystemMsg({ type: 'error', text: t('spoolbuddy.settings.systemFieldsRequired', 'Backend URL is required.') });
+      return;
+    }
+
+    setSystemBusy(true);
+    setSystemMsg(null);
+    try {
+      await spoolbuddyApi.updateSystemConfig(
+        device.device_id,
+        backendUrl.trim(),
+        apiToken.trim() || undefined
+      );
+      setSystemMsg({ type: 'ok', text: t('spoolbuddy.settings.systemQueued', 'Config queued.') });
+    } catch (e) {
+      setSystemMsg({ type: 'error', text: e instanceof Error ? e.message : t('common.error', 'Error') });
+    } finally {
+      setSystemBusy(false);
+    }
+  };
 
 
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
@@ -124,6 +161,116 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
         <span className="text-zinc-500">Device ID</span>
         <span className="text-zinc-500">Device ID</span>
         <span className="text-zinc-400 font-mono">{device.device_id}</span>
         <span className="text-zinc-400 font-mono">{device.device_id}</span>
       </div>
       </div>
+
+      <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
+        {/* Backend/Auth Config */}
+        <div className="bg-zinc-800 rounded-lg p-4 space-y-3">
+          <h3 className="text-sm font-semibold text-zinc-300">
+            {t('spoolbuddy.settings.systemConfig', 'Backend & Auth')}
+          </h3>
+
+          <div className="space-y-2">
+            <label className="text-xs text-zinc-500 block">
+              {t('spoolbuddy.settings.backendUrl', 'Bambuddy Backend URL')}
+            </label>
+            <input
+              value={backendUrl}
+              onChange={(e) => setBackendUrl(e.target.value)}
+              placeholder="http://192.168.1.100:5000"
+              className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm"
+            />
+          </div>
+
+          <div className="space-y-2">
+            <label className="text-xs text-zinc-500 block">
+              {t('spoolbuddy.settings.apiToken', 'API Token')}
+            </label>
+            <input
+              type="password"
+              value={apiToken}
+              onChange={(e) => setApiToken(e.target.value)}
+              placeholder={t('spoolbuddy.settings.apiTokenPlaceholder', 'Enter API token')}
+              className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-zinc-100 text-sm"
+            />
+          </div>
+
+          <div className="flex gap-2">
+            <button
+              onClick={saveConfig}
+              disabled={systemBusy}
+              className="px-3 py-2 rounded bg-green-700 hover:bg-green-600 disabled:bg-zinc-700 text-sm font-medium text-zinc-100"
+            >
+              {t('spoolbuddy.settings.saveConfig', 'Save Config')}
+            </button>
+          </div>
+
+          {systemMsg && (
+            <div className={`text-xs ${systemMsg.type === 'ok' ? 'text-green-400' : 'text-red-400'}`}>
+              {systemMsg.text}
+            </div>
+          )}
+        </div>
+
+        {/* Diagnostic Buttons */}
+        <div className="bg-zinc-800 rounded-lg p-4 space-y-3">
+          {/* NFC Diagnostic Button */}
+          <button
+            onClick={() => setDiagnosticOpen('nfc')}
+            className="w-full bg-blue-700 hover:bg-blue-600 transition-colors rounded-lg p-3 text-left"
+          >
+            <div className="flex items-center gap-2 mb-1">
+              <Wand2 className="w-4 h-4 text-blue-300" />
+              <span className="text-sm font-semibold text-blue-100">
+                {t('spoolbuddy.settings.nfcDiagnostic', 'NFC Diagnostic')}
+              </span>
+            </div>
+            <p className="text-xs text-blue-200/70">
+              {t('spoolbuddy.settings.testNfc', 'Test reader')}
+            </p>
+          </button>
+
+          {/* Scale Diagnostic Button */}
+          <button
+            onClick={() => setDiagnosticOpen('scale')}
+            className="w-full bg-yellow-700 hover:bg-yellow-600 transition-colors rounded-lg p-3 text-left"
+          >
+            <div className="flex items-center gap-2 mb-1">
+              <Zap className="w-4 h-4 text-yellow-300" />
+              <span className="text-sm font-semibold text-yellow-100">
+                {t('spoolbuddy.settings.scaleDiagnostic', 'Scale Diagnostic')}
+              </span>
+            </div>
+            <p className="text-xs text-yellow-200/70">
+              {t('spoolbuddy.settings.testScale', 'Test accuracy')}
+            </p>
+          </button>
+
+          {/* Read Tag Diagnostic Button */}
+          <button
+            onClick={() => setDiagnosticOpen('read_tag')}
+            className="w-full bg-emerald-700 hover:bg-emerald-600 transition-colors rounded-lg p-3 text-left"
+          >
+            <div className="flex items-center gap-2 mb-1">
+              <FileText className="w-4 h-4 text-emerald-300" />
+              <span className="text-sm font-semibold text-emerald-100">
+                {t('spoolbuddy.settings.readTagDiagnostic', 'Read Tag Diagnostic')}
+              </span>
+            </div>
+            <p className="text-xs text-emerald-200/70">
+              {t('spoolbuddy.settings.testReadTag', 'Run read_tag.py')}
+            </p>
+          </button>
+        </div>
+      </div>
+
+      {/* Diagnostic Modal */}
+      {diagnosticOpen && device && (
+        <DiagnosticModal
+          type={diagnosticOpen}
+          deviceId={device.device_id}
+          onClose={() => setDiagnosticOpen(null)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 633 - 151
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -2,13 +2,36 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
 import { useOutletContext } from 'react-router-dom';
 import { useOutletContext } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
+import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
-import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
+import {
+  api,
+  spoolbuddyApi,
+  type InventorySpool,
+  type LocalPreset,
+  type SlicerSetting,
+  type SpoolCatalogEntry,
+} from '../../api/client';
+import { getCurrencySymbol } from '../../utils/currency';
+import { FilamentSection } from '../../components/spool-form/FilamentSection';
+import { ColorSection } from '../../components/spool-form/ColorSection';
+import { AdditionalSection } from '../../components/spool-form/AdditionalSection';
+import { PAProfileSection } from '../../components/spool-form/PAProfileSection';
+import type { ColorPreset, PrinterWithCalibrations, SpoolFormData } from '../../components/spool-form/types';
+import { defaultFormData, validateForm } from '../../components/spool-form/types';
+import {
+  buildFilamentOptions,
+  extractBrandsFromPresets,
+  findPresetOption,
+  loadRecentColors,
+  parsePresetName,
+  saveRecentColor,
+} from '../../components/spool-form/utils';
+import { MATERIALS } from '../../components/spool-form/constants';
 
 
 type Tab = 'existing' | 'new' | 'replace';
 type Tab = 'existing' | 'new' | 'replace';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
-
-const COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
+const SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA', 'HIPS'];
 
 
 export function SpoolBuddyWriteTagPage() {
 export function SpoolBuddyWriteTagPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -19,16 +42,10 @@ export function SpoolBuddyWriteTagPage() {
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');
   const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');
   const [writeMessage, setWriteMessage] = useState('');
   const [writeMessage, setWriteMessage] = useState('');
+  const [untagging, setUntagging] = useState(false);
   const [tagOnReader, setTagOnReader] = useState(false);
   const [tagOnReader, setTagOnReader] = useState(false);
   const [tagUid, setTagUid] = useState<string | null>(null);
   const [tagUid, setTagUid] = useState<string | null>(null);
-
-  // New spool form state
-  const [newMaterial, setNewMaterial] = useState('PLA');
-  const [newColorName, setNewColorName] = useState('');
-  const [newColorHex, setNewColorHex] = useState('#00AE42');
-  const [newBrand, setNewBrand] = useState('');
-  const [newWeight, setNewWeight] = useState(1000);
-  const [creating, setCreating] = useState(false);
+  const [diagnosticOpen, setDiagnosticOpen] = useState<'scale' | 'nfc' | null>(null);
 
 
   const { data: spools = [], refetch: refetchSpools } = useQuery({
   const { data: spools = [], refetch: refetchSpools } = useQuery({
     queryKey: ['inventory-spools'],
     queryKey: ['inventory-spools'],
@@ -42,8 +59,14 @@ export function SpoolBuddyWriteTagPage() {
     refetchInterval: 5000,
     refetchInterval: 5000,
   });
   });
 
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
   const device = devices[0];
   const device = devices[0];
   const deviceOnline = sbState.deviceOnline;
   const deviceOnline = sbState.deviceOnline;
+  const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
 
 
   // Filter spools based on tab
   // Filter spools based on tab
   const filteredSpools = useMemo(() => {
   const filteredSpools = useMemo(() => {
@@ -51,7 +74,7 @@ export function SpoolBuddyWriteTagPage() {
     if (activeTab === 'existing') {
     if (activeTab === 'existing') {
       list = spools.filter(s => !s.tag_uid && !s.archived_at);
       list = spools.filter(s => !s.tag_uid && !s.archived_at);
     } else if (activeTab === 'replace') {
     } else if (activeTab === 'replace') {
-      list = spools.filter(s => s.tag_uid && !s.archived_at);
+      list = spools.filter(s => (s.tag_uid || s.tray_uuid) && !s.archived_at);
     } else {
     } else {
       return [];
       return [];
     }
     }
@@ -157,50 +180,60 @@ export function SpoolBuddyWriteTagPage() {
     setWriteMessage('');
     setWriteMessage('');
   };
   };
 
 
-  const handleCreateAndSelect = async () => {
-    setCreating(true);
+  const handleUntagSpool = async () => {
+    if (!selectedSpool || !isReplaceTagged(selectedSpool)) return;
+    setUntagging(true);
+    setWriteStatus('idle');
+    setWriteMessage('');
     try {
     try {
-      const rgba = newColorHex.replace('#', '') + 'FF';
-      const spool = await api.createSpool({
-        material: newMaterial,
-        subtype: null,
-        color_name: newColorName || null,
-        rgba,
-        brand: newBrand || null,
-        label_weight: newWeight,
-        core_weight: 250,
-        core_weight_catalog_id: null,
-        weight_used: 0,
-        slicer_filament: null,
-        slicer_filament_name: null,
-        nozzle_temp_min: null,
-        nozzle_temp_max: null,
-        note: null,
-        added_full: true,
-        last_used: null,
-        encode_time: null,
-        tag_uid: null,
-        tray_uuid: null,
-        data_origin: null,
-        tag_type: null,
-        cost_per_kg: null,
-        last_scale_weight: null,
-        last_weighed_at: null,
+      await api.linkTagToSpool(selectedSpool.id, {
+        tag_uid: '',
+        tray_uuid: '',
+        data_origin: 'manual',
       });
       });
-      setSelectedSpool(spool);
-      refetchSpools();
+      await refetchSpools();
+      setSelectedSpool(null);
+      setWriteStatus('success');
+      setWriteMessage(t('spoolbuddy.writeTag.untagSuccess', 'Tag removed from spool'));
+      setTimeout(() => {
+        setWriteStatus('idle');
+        setWriteMessage('');
+      }, 2500);
     } catch {
     } catch {
-      setWriteMessage(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
       setWriteStatus('error');
       setWriteStatus('error');
+      setWriteMessage(t('spoolbuddy.writeTag.untagFailed', 'Failed to remove tag from spool'));
     } finally {
     } finally {
-      setCreating(false);
+      setUntagging(false);
     }
     }
   };
   };
 
 
+  const handleSpoolCreated = useCallback((createdSpool: InventorySpool) => {
+    setSelectedSpool(createdSpool);
+    setWriteStatus('idle');
+    setWriteMessage('');
+    void refetchSpools();
+  }, [refetchSpools]);
+
   const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success';
   const canWrite = selectedSpool && deviceOnline && writeStatus !== 'writing' && writeStatus !== 'success';
 
 
+  const handleOpenNfcDiagnostics = useCallback(() => {
+    setDiagnosticOpen('nfc');
+  }, []);
+
+  const handleOpenScaleDiagnostics = useCallback(() => {
+    setDiagnosticOpen('scale');
+  }, []);
+
   return (
   return (
     <div className="flex flex-col h-full">
     <div className="flex flex-col h-full">
+      {diagnosticOpen && (
+        <DiagnosticModal
+          type={diagnosticOpen}
+          onClose={() => setDiagnosticOpen(null)}
+          deviceId={device?.device_id || ''}
+        />
+      )}
+
       {/* Tab bar */}
       {/* Tab bar */}
       <div className="flex border-b border-bambu-dark-tertiary shrink-0">
       <div className="flex border-b border-bambu-dark-tertiary shrink-0">
         {([
         {([
@@ -227,19 +260,9 @@ export function SpoolBuddyWriteTagPage() {
         {/* Left panel — spool list or form */}
         {/* Left panel — spool list or form */}
         <div className="flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary">
         <div className="flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary">
           {activeTab === 'new' ? (
           {activeTab === 'new' ? (
-            <NewSpoolForm
-              material={newMaterial}
-              setMaterial={setNewMaterial}
-              colorName={newColorName}
-              setColorName={setNewColorName}
-              colorHex={newColorHex}
-              setColorHex={setNewColorHex}
-              brand={newBrand}
-              setBrand={setNewBrand}
-              weight={newWeight}
-              setWeight={setNewWeight}
-              creating={creating}
-              onSubmit={handleCreateAndSelect}
+            <NewSpoolTouchForm
+              currencySymbol={currencySymbol}
+              onCreated={handleSpoolCreated}
               selectedSpool={selectedSpool}
               selectedSpool={selectedSpool}
               t={t}
               t={t}
             />
             />
@@ -295,9 +318,14 @@ export function SpoolBuddyWriteTagPage() {
             deviceOnline={deviceOnline}
             deviceOnline={deviceOnline}
             canWrite={!!canWrite}
             canWrite={!!canWrite}
             isReplace={activeTab === 'replace'}
             isReplace={activeTab === 'replace'}
+            canUntag={activeTab === 'replace' && !!selectedSpool && isReplaceTagged(selectedSpool)}
+            untagging={untagging}
             onWrite={handleWriteTag}
             onWrite={handleWriteTag}
+            onUntag={handleUntagSpool}
             onCancel={handleCancelWrite}
             onCancel={handleCancelWrite}
             onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}
             onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}
+            onOpenNfcDiagnostics={handleOpenNfcDiagnostics}
+            onOpenScaleDiagnostics={handleOpenScaleDiagnostics}
             t={t}
             t={t}
           />
           />
         </div>
         </div>
@@ -306,6 +334,10 @@ export function SpoolBuddyWriteTagPage() {
   );
   );
 }
 }
 
 
+function isReplaceTagged(spool: InventorySpool): boolean {
+  return !!(spool.tag_uid || spool.tray_uuid);
+}
+
 // --- Spool list item ---
 // --- Spool list item ---
 function SpoolListItem({ spool, selected, showTag, onClick }: {
 function SpoolListItem({ spool, selected, showTag, onClick }: {
   spool: InventorySpool;
   spool: InventorySpool;
@@ -358,118 +390,535 @@ function SpoolListItem({ spool, selected, showTag, onClick }: {
   );
   );
 }
 }
 
 
-// --- New spool form ---
-function NewSpoolForm({ material, setMaterial, colorName, setColorName, colorHex, setColorHex, brand, setBrand, weight, setWeight, creating, onSubmit, selectedSpool, t }: {
-  material: string;
-  setMaterial: (v: string) => void;
-  colorName: string;
-  setColorName: (v: string) => void;
-  colorHex: string;
-  setColorHex: (v: string) => void;
-  brand: string;
-  setBrand: (v: string) => void;
-  weight: number;
-  setWeight: (v: number) => void;
-  creating: boolean;
-  onSubmit: () => void;
+type NewSpoolSubTab = 'filament' | 'pa-profile';
+type NewSpoolViewMode = 'simple' | 'full';
+
+// --- New spool touch form (mirrors Add Spool fields/options in kiosk-friendly layout) ---
+function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
+  currencySymbol: string;
+  onCreated: (spool: InventorySpool) => void;
   selectedSpool: InventorySpool | null;
   selectedSpool: InventorySpool | null;
   t: (key: string, fallback: string) => string;
   t: (key: string, fallback: string) => string;
 }) {
 }) {
-  if (selectedSpool) {
-    return (
-      <div className="flex flex-col items-center justify-center h-full p-6 text-center">
-        <div
-          className="w-12 h-12 rounded-full mb-4 border border-white/10"
-          style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
-        />
-        <p className="text-white font-medium">
-          {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
-        </p>
-        {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
-        <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
-        <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
-      </div>
-    );
-  }
+  const [viewMode, setViewMode] = useState<NewSpoolViewMode>('simple');
+  const [activeSubTab, setActiveSubTab] = useState<NewSpoolSubTab>('filament');
+  const [formData, setFormData] = useState<SpoolFormData>(defaultFormData);
+  const [errors, setErrors] = useState<Partial<Record<keyof SpoolFormData, string>>>({});
+  const [quickAdd, setQuickAdd] = useState(false);
+  const [quantity, setQuantity] = useState(1);
+  const [creating, setCreating] = useState(false);
+  const [createError, setCreateError] = useState<string | null>(null);
+
+  const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
+  const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
+  const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
+  const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
+  const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
+  const [colorCatalog, setColorCatalog] = useState<
+    { manufacturer: string; color_name: string; hex_color: string; material: string | null }[]
+  >([]);
+  const [presetInputValue, setPresetInputValue] = useState('');
+  const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
+
+  const [printersWithCalibrations, setPrintersWithCalibrations] = useState<PrinterWithCalibrations[]>([]);
+  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
+  const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
+
+  useEffect(() => {
+    setRecentColors(loadRecentColors());
+  }, []);
+
+  useEffect(() => {
+    const fetchData = async () => {
+      // Only load full data when in full view mode
+      if (viewMode !== 'full') {
+        return;
+      }
+
+      setLoadingCloudPresets(true);
+      try {
+        const status = await api.getCloudStatus();
+        setCloudAuthenticated(status.is_authenticated);
+        if (status.is_authenticated) {
+          const presets = await api.getFilamentPresets();
+          setCloudPresets(presets);
+        }
+      } catch {
+        setCloudAuthenticated(false);
+      } finally {
+        setLoadingCloudPresets(false);
+      }
+
+      api.getSpoolCatalog().then(setSpoolCatalog).catch(() => undefined);
+      api.getColorCatalog().then(setColorCatalog).catch(() => undefined);
+      api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(() => undefined);
+
+      try {
+        const printers = await api.getPrinters();
+        const statuses = await Promise.all(printers.map(p => api.getPrinterStatus(p.id).catch(() => null)));
+        const results: PrinterWithCalibrations[] = [];
+        for (let i = 0; i < printers.length; i++) {
+          const printer = printers[i];
+          const status = statuses[i];
+          const connected = status?.connected ?? false;
+          let calibrations: PrinterWithCalibrations['calibrations'] = [];
+          if (connected) {
+            try {
+              const kRes = await api.getKProfiles(printer.id);
+              calibrations = kRes.profiles.map(p => ({
+                cali_idx: p.slot_id,
+                filament_id: p.filament_id,
+                setting_id: p.setting_id || '',
+                name: p.name,
+                k_value: parseFloat(p.k_value) || 0,
+                n_coef: parseFloat(p.n_coef) || 0,
+                extruder_id: p.extruder_id,
+                nozzle_diameter: p.nozzle_diameter,
+              }));
+            } catch {
+              // ignore per-printer unsupported profile endpoints
+            }
+          }
+          results.push({ printer: { ...printer, connected }, calibrations });
+        }
+        setPrintersWithCalibrations(results);
+      } catch {
+        // ignore calibration loading errors on kiosk form
+      }
+    };
+
+    fetchData();
+  }, [viewMode]);
+
+  useEffect(() => {
+    if (printersWithCalibrations.length > 0) {
+      setExpandedPrinters(new Set(printersWithCalibrations.map(p => String(p.printer.id))));
+    }
+  }, [printersWithCalibrations]);
+
+  const filamentOptions = useMemo(
+    () => buildFilamentOptions(cloudPresets, new Set(), localPresets),
+    [cloudPresets, localPresets],
+  );
+
+  const selectedPresetOption = useMemo(
+    () => findPresetOption(formData.slicer_filament, filamentOptions),
+    [formData.slicer_filament, filamentOptions],
+  );
+
+  const baseAvailableBrands = useMemo(() => {
+    const presetBrands = extractBrandsFromPresets(cloudPresets, localPresets);
+    const catalogBrands = colorCatalog
+      .map(entry => entry.manufacturer?.trim())
+      .filter((brand): brand is string => !!brand);
+    return Array.from(new Set<string>([...presetBrands, ...catalogBrands])).sort((a, b) => a.localeCompare(b));
+  }, [cloudPresets, localPresets, colorCatalog]);
+
+  const baseAvailableMaterials = useMemo(() => {
+    const catalogMaterials = colorCatalog
+      .map(entry => entry.material?.trim())
+      .filter((material): material is string => !!material);
+    return Array.from(new Set<string>([...MATERIALS, ...catalogMaterials])).sort((a, b) => a.localeCompare(b));
+  }, [colorCatalog]);
+
+  const brandMaterialPairs = useMemo(() => {
+    const pairs: Array<{ brand: string; material: string }> = [];
+    for (const entry of colorCatalog) {
+      const brand = entry.manufacturer?.trim();
+      const material = entry.material?.trim();
+      if (brand && material) pairs.push({ brand, material });
+    }
+    for (const preset of cloudPresets) {
+      const parsed = parsePresetName(preset.name);
+      if (parsed.brand && parsed.material) pairs.push({ brand: parsed.brand, material: parsed.material });
+    }
+    for (const preset of localPresets) {
+      const parsed = parsePresetName(preset.name);
+      const brand = preset.filament_vendor?.trim() || parsed.brand;
+      const material = parsed.material;
+      if (brand && material) pairs.push({ brand, material });
+    }
+    return pairs;
+  }, [cloudPresets, colorCatalog, localPresets]);
+
+  const brandToMaterials = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    for (const pair of brandMaterialPairs) {
+      const brandKey = pair.brand.toLowerCase();
+      const materialKey = pair.material.toLowerCase();
+      if (!map.has(brandKey)) map.set(brandKey, new Set());
+      map.get(brandKey)!.add(materialKey);
+    }
+    return map;
+  }, [brandMaterialPairs]);
+
+  const materialToBrands = useMemo(() => {
+    const map = new Map<string, Set<string>>();
+    for (const pair of brandMaterialPairs) {
+      const brandKey = pair.brand.toLowerCase();
+      const materialKey = pair.material.toLowerCase();
+      if (!map.has(materialKey)) map.set(materialKey, new Set());
+      map.get(materialKey)!.add(brandKey);
+    }
+    return map;
+  }, [brandMaterialPairs]);
+
+  const availableBrands = useMemo(() => {
+    if (!formData.material) return baseAvailableBrands;
+    const materialKey = formData.material.toLowerCase();
+    const brandKeys = materialToBrands.get(materialKey);
+    if (!brandKeys || brandKeys.size === 0) return baseAvailableBrands;
+    return baseAvailableBrands.filter(brand => brandKeys.has(brand.toLowerCase()));
+  }, [baseAvailableBrands, formData.material, materialToBrands]);
+
+  const availableMaterials = useMemo(() => {
+    if (!formData.brand) return baseAvailableMaterials;
+    const brandKey = formData.brand.toLowerCase();
+    const materialKeys = brandToMaterials.get(brandKey);
+    if (!materialKeys || materialKeys.size === 0) return baseAvailableMaterials;
+    return baseAvailableMaterials.filter(material => materialKeys.has(material.toLowerCase()));
+  }, [baseAvailableMaterials, formData.brand, brandToMaterials]);
+
+  const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
+    setFormData(prev => ({ ...prev, [key]: value }));
+    if (errors[key]) {
+      setErrors(prev => ({ ...prev, [key]: undefined }));
+    }
+  };
+
+  const handleColorUsed = (color: ColorPreset) => {
+    setRecentColors(prev => saveRecentColor(color, prev));
+  };
+
+  const saveKProfiles = async (spoolId: number) => {
+    if (selectedProfiles.size === 0) {
+      try {
+        await api.saveSpoolKProfiles(spoolId, []);
+      } catch {
+        // ignore
+      }
+      return;
+    }
+
+    const profiles = [];
+    for (const key of selectedProfiles) {
+      const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
+      const printerId = parseInt(printerIdStr);
+      const caliIdx = parseInt(caliIdxStr);
+      const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
+
+      const pc = printersWithCalibrations.find(p => p.printer.id === printerId);
+      if (pc) {
+        const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
+        if (cal) {
+          profiles.push({
+            printer_id: printerId,
+            extruder,
+            nozzle_diameter: cal.nozzle_diameter || '0.4',
+            k_value: cal.k_value,
+            name: cal.name || null,
+            cali_idx: cal.cali_idx,
+            setting_id: cal.setting_id || null,
+          });
+        }
+      }
+    }
+
+    if (profiles.length > 0) {
+      await api.saveSpoolKProfiles(spoolId, profiles);
+    }
+  };
+
+  const handleCreate = async () => {
+    setCreateError(null);
+    const validation = validateForm(formData, viewMode === 'simple' ? true : quickAdd);
+    if (!validation.isValid) {
+      setErrors(validation.errors);
+      setActiveSubTab('filament');
+      return;
+    }
+
+    const presetName = selectedPresetOption?.displayName || presetInputValue || null;
+    const payload = {
+      material: formData.material,
+      subtype: formData.subtype || null,
+      brand: formData.brand || null,
+      color_name: formData.color_name || null,
+      rgba: formData.rgba || null,
+      label_weight: formData.label_weight,
+      core_weight: formData.core_weight,
+      core_weight_catalog_id: formData.core_weight_catalog_id,
+      weight_used: formData.weight_used,
+      slicer_filament: formData.slicer_filament || null,
+      slicer_filament_name: presetName,
+      nozzle_temp_min: null,
+      nozzle_temp_max: null,
+      note: formData.note || null,
+      cost_per_kg: formData.cost_per_kg,
+      added_full: null,
+      last_used: null,
+      encode_time: null,
+      tag_uid: null,
+      tray_uuid: null,
+      data_origin: null,
+      tag_type: null,
+      last_scale_weight: null,
+      last_weighed_at: null,
+    };
+
+    setCreating(true);
+    try {
+      if (quantity > 1) {
+        const created = await api.bulkCreateSpools(payload, quantity);
+        for (const spool of created) {
+          await saveKProfiles(spool.id);
+        }
+        if (created.length > 0) onCreated(created[0]);
+      } else {
+        const created = await api.createSpool(payload);
+        await saveKProfiles(created.id);
+        onCreated(created);
+      }
+    } catch {
+      setCreateError(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  const simpleColorHex = `#${(formData.rgba || '808080FF').slice(0, 6)}`;
 
 
   return (
   return (
-    <div className="p-4 space-y-4 overflow-y-auto">
-      {/* Material */}
-      <div>
-        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
-        <select
-          value={material}
-          onChange={(e) => setMaterial(e.target.value)}
-          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
-        >
-          {COMMON_MATERIALS.map(m => <option key={m} value={m}>{m}</option>)}
-        </select>
+    <div className="p-3 space-y-3 overflow-y-auto h-full">
+      <div className="flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
+        <span className="text-sm text-zinc-200">{t('spoolbuddy.writeTag.viewMode', 'View')}</span>
+        <div className="flex rounded-lg overflow-hidden border border-bambu-dark-tertiary">
+          <button
+            type="button"
+            onClick={() => setViewMode('simple')}
+            className={`px-3 py-1.5 text-xs font-medium ${
+              viewMode === 'simple' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'
+            }`}
+          >
+            {t('spoolbuddy.writeTag.simpleView', 'Simple')}
+          </button>
+          <button
+            type="button"
+            onClick={() => setViewMode('full')}
+            className={`px-3 py-1.5 text-xs font-medium ${
+              viewMode === 'full' ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark text-zinc-400'
+            }`}
+          >
+            {t('spoolbuddy.writeTag.fullView', 'Full')}
+          </button>
+        </div>
       </div>
       </div>
 
 
-      {/* Color name + picker */}
-      <div className="flex gap-3">
-        <div className="flex-1">
-          <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
-          <input
-            type="text"
-            value={colorName}
-            onChange={(e) => setColorName(e.target.value)}
-            placeholder="Jade White"
-            className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
-          />
-        </div>
-        <div>
-          <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
-          <input
-            type="color"
-            value={colorHex}
-            onChange={(e) => setColorHex(e.target.value)}
-            className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
-          />
-        </div>
+      {viewMode === 'simple' ? (
+        selectedSpool ? (
+          <div className="flex flex-col items-center justify-center h-full p-6 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+            <div
+              className="w-12 h-12 rounded-full mb-4 border border-white/10"
+              style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
+            />
+            <p className="text-white font-medium">
+              {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
+            </p>
+            {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
+            <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
+            <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
+          </div>
+        ) : (
+          <div className="p-4 space-y-4 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+            <div>
+              <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.material', 'Material')}</label>
+              <select
+                value={formData.material}
+                onChange={(e) => updateField('material', e.target.value)}
+                className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
+              >
+                {SIMPLE_COMMON_MATERIALS.map((m) => (
+                  <option key={m} value={m}>{m}</option>
+                ))}
+              </select>
+            </div>
+
+            <div className="flex gap-3">
+              <div className="flex-1">
+                <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.colorName', 'Color Name')}</label>
+                <input
+                  type="text"
+                  value={formData.color_name}
+                  onChange={(e) => updateField('color_name', e.target.value)}
+                  placeholder="Jade White"
+                  className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
+                />
+              </div>
+              <div>
+                <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.color', 'Color')}</label>
+                <input
+                  type="color"
+                  value={simpleColorHex}
+                  onChange={(e) => updateField('rgba', e.target.value.replace('#', '').toUpperCase() + 'FF')}
+                  className="w-10 h-9 bg-transparent border border-bambu-dark-tertiary rounded cursor-pointer"
+                />
+              </div>
+            </div>
+
+            <div>
+              <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
+              <input
+                type="text"
+                value={formData.brand}
+                onChange={(e) => updateField('brand', e.target.value)}
+                placeholder="Polymaker"
+                className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
+              />
+            </div>
+
+            <div>
+              <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
+              <input
+                type="number"
+                value={formData.label_weight}
+                onChange={(e) => updateField('label_weight', parseInt(e.target.value) || 0)}
+                min={0}
+                max={10000}
+                className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
+              />
+            </div>
+
+            <button
+              onClick={handleCreate}
+              disabled={creating || !formData.material}
+              className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
+            >
+              {creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
+            </button>
+          </div>
+        )
+      ) : (
+        <>
+      <div className="flex items-center justify-between px-2 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
+        <span className="text-sm text-zinc-200">{t('inventory.quickAdd', 'Quick Add')}</span>
+        <button
+          type="button"
+          onClick={() => setQuickAdd((prev) => !prev)}
+          className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+            quickAdd ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+          }`}
+        >
+          <span className={`inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform ${quickAdd ? 'translate-x-6' : 'translate-x-1'}`} />
+        </button>
       </div>
       </div>
 
 
-      {/* Brand */}
-      <div>
-        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.brand', 'Brand')}</label>
-        <input
-          type="text"
-          value={brand}
-          onChange={(e) => setBrand(e.target.value)}
-          placeholder="Polymaker"
-          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-bambu-green"
-        />
+      <div className="flex border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+        <button
+          onClick={() => setActiveSubTab('filament')}
+          className={`flex-1 py-2.5 text-sm font-medium ${
+            activeSubTab === 'filament' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'
+          }`}
+        >
+          {t('inventory.filamentInfoTab', 'Filament')}
+        </button>
+        {!quickAdd && (
+          <button
+            onClick={() => setActiveSubTab('pa-profile')}
+            className={`flex-1 py-2.5 text-sm font-medium ${
+              activeSubTab === 'pa-profile' ? 'bg-bambu-green/15 text-bambu-green' : 'bg-bambu-dark-secondary text-zinc-400'
+            }`}
+          >
+            {t('inventory.paProfileTab', 'PA Profile')}
+          </button>
+        )}
       </div>
       </div>
 
 
-      {/* Weight */}
-      <div>
-        <label className="block text-xs text-zinc-400 mb-1">{t('spoolbuddy.writeTag.weight', 'Weight (g)')}</label>
-        <input
-          type="number"
-          value={weight}
-          onChange={(e) => setWeight(parseInt(e.target.value) || 0)}
-          min={0}
-          max={10000}
-          className="w-full px-3 py-2 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-sm text-white focus:outline-none focus:border-bambu-green"
-        />
+      <div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg p-3">
+        {activeSubTab === 'filament' ? (
+          <div className="space-y-4">
+            <FilamentSection
+              formData={formData}
+              updateField={updateField}
+              cloudAuthenticated={cloudAuthenticated}
+              loadingCloudPresets={loadingCloudPresets}
+              presetInputValue={presetInputValue}
+              setPresetInputValue={setPresetInputValue}
+              selectedPresetOption={selectedPresetOption}
+              filamentOptions={filamentOptions}
+              availableBrands={availableBrands}
+              availableMaterials={availableMaterials}
+              quickAdd={quickAdd}
+              quantity={quantity}
+              onQuantityChange={setQuantity}
+              errors={errors}
+            />
+
+            <ColorSection
+              formData={formData}
+              updateField={updateField}
+              recentColors={recentColors}
+              onColorUsed={handleColorUsed}
+              catalogColors={colorCatalog}
+            />
+
+            <AdditionalSection
+              formData={formData}
+              updateField={updateField}
+              spoolCatalog={spoolCatalog}
+              currencySymbol={currencySymbol}
+            />
+          </div>
+        ) : (
+          <PAProfileSection
+            formData={formData}
+            updateField={updateField}
+            printersWithCalibrations={printersWithCalibrations}
+            selectedProfiles={selectedProfiles}
+            setSelectedProfiles={setSelectedProfiles}
+            expandedPrinters={expandedPrinters}
+            setExpandedPrinters={setExpandedPrinters}
+          />
+        )}
       </div>
       </div>
+        </>
+      )}
 
 
-      {/* Create button */}
-      <button
-        onClick={onSubmit}
-        disabled={creating || !material}
-        className="w-full py-2.5 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
-      >
-        {creating
-          ? t('spoolbuddy.writeTag.creating', 'Creating...')
-          : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
-      </button>
+      {createError && (
+        <div className="text-sm text-red-400 bg-red-900/20 border border-red-900/40 rounded-lg px-3 py-2">
+          {createError}
+        </div>
+      )}
+
+      {viewMode === 'full' && (
+        <button
+          onClick={handleCreate}
+          disabled={creating}
+          className="w-full py-3 bg-bambu-green hover:bg-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded transition-colors"
+        >
+          {creating ? t('spoolbuddy.writeTag.creating', 'Creating...') : t('spoolbuddy.writeTag.createSpool', 'Create Spool')}
+        </button>
+      )}
+
+      {viewMode === 'full' && selectedSpool && (
+        <div className="flex flex-col items-center justify-center p-4 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+          <div
+            className="w-12 h-12 rounded-full mb-4 border border-white/10"
+            style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
+          />
+          <p className="text-white font-medium">
+            {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
+          </p>
+          {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
+          <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
+          <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }
 
 
 // --- NFC status panel ---
 // --- NFC status panel ---
-function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, onWrite, onCancel, onRetry, t }: {
+function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader, tagUid, deviceOnline, canWrite, isReplace, canUntag, untagging, onWrite, onUntag, onCancel, onRetry, onOpenNfcDiagnostics, onOpenScaleDiagnostics, t }: {
   writeStatus: WriteStatus;
   writeStatus: WriteStatus;
   writeMessage: string;
   writeMessage: string;
   selectedSpool: InventorySpool | null;
   selectedSpool: InventorySpool | null;
@@ -478,9 +927,14 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
   deviceOnline: boolean;
   deviceOnline: boolean;
   canWrite: boolean;
   canWrite: boolean;
   isReplace: boolean;
   isReplace: boolean;
+  canUntag: boolean;
+  untagging: boolean;
   onWrite: () => void;
   onWrite: () => void;
+  onUntag: () => void;
   onCancel: () => void;
   onCancel: () => void;
   onRetry: () => void;
   onRetry: () => void;
+  onOpenNfcDiagnostics: () => void;
+  onOpenScaleDiagnostics: () => void;
   t: (key: string, fallback: string) => string;
   t: (key: string, fallback: string) => string;
 }) {
 }) {
   // Success state
   // Success state
@@ -630,6 +1084,34 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
           ? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')
           ? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')
           : t('spoolbuddy.writeTag.writeTag', 'Write Tag')}
           : t('spoolbuddy.writeTag.writeTag', 'Write Tag')}
       </button>
       </button>
+
+      {isReplace && canUntag && (
+        <button
+          onClick={onUntag}
+          disabled={untagging}
+          className="w-full py-2.5 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary disabled:opacity-40 disabled:cursor-not-allowed text-zinc-200 rounded-lg transition-colors text-sm"
+        >
+          {untagging
+            ? t('spoolbuddy.writeTag.untagging', 'Removing tag...')
+            : t('spoolbuddy.writeTag.untagSpool', 'Untag Selected Spool')}
+        </button>
+      )}
+
+      {/* Diagnostic buttons */}
+      <div className="w-full flex gap-2 pt-2">
+        <button
+          onClick={onOpenNfcDiagnostics}
+          className="flex-1 py-2 px-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-300 text-xs rounded transition-colors"
+        >
+          {t('spoolbuddy.writeTag.nfcDiagnostics', 'NFC Diag')}
+        </button>
+        <button
+          onClick={onOpenScaleDiagnostics}
+          className="flex-1 py-2 px-2 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary text-zinc-300 text-xs rounded transition-colors"
+        >
+          {t('spoolbuddy.writeTag.scaleDiagnostics', 'Scale Diag')}
+        </button>
+      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 10 - 29
spoolbuddy/README.md

@@ -53,18 +53,15 @@ ls /dev/i2c-*
 Add the following lines under the `[all]` section:
 Add the following lines under the `[all]` section:
 
 
 ```
 ```
-# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)
-dtparam=i2c_vc=on
+# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3)
+dtparam=i2c_arm=on
 
 
 # SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180)
 # SpoolBuddy: Disable SPI auto CS (manual CS on GPIO23 for PN5180)
 dtoverlay=spi0-0cs
 dtoverlay=spi0-0cs
 ```
 ```
 
 
-- `i2c_vc=on` enables I2C bus 0 (GPIO0/GPIO1). The default `i2c_arm` only
-  enables bus 1 (GPIO2/GPIO3). The NAU7802 is wired to bus 0.
-- `spi0-0cs` disables the kernel SPI driver's automatic chip-select. We use
+- `i2c_arm=on` enables I2C bus 1 (GPIO2/GPIO3). The NAU7802 is wired to bus 1.
   manual CS on GPIO23 because the driver's CS timing doesn't meet the PN5180's
   manual CS on GPIO23 because the driver's CS timing doesn't meet the PN5180's
-  requirements.
 
 
 Then reboot:
 Then reboot:
 
 
@@ -75,10 +72,10 @@ sudo reboot
 Verify after reboot:
 Verify after reboot:
 
 
 ```bash
 ```bash
-ls /dev/i2c-0
+ls /dev/i2c-1
 # Should exist
 # Should exist
 
 
-sudo i2cdetect -y 0
+sudo i2cdetect -y 1
 # Should show 0x2A (NAU7802)
 # Should show 0x2A (NAU7802)
 ```
 ```
 
 
@@ -90,9 +87,6 @@ sudo apt install python3-spidev python3-libgpiod gpiod libgpiod3 i2c-tools
 
 
 - `python3-spidev` / `libgpiod3` — system libraries for SPI and GPIO access
 - `python3-spidev` / `libgpiod3` — system libraries for SPI and GPIO access
 - `gpiod` — command-line GPIO tools (useful for debugging)
 - `gpiod` — command-line GPIO tools (useful for debugging)
-- `i2c-tools` — I2C diagnostic tools (`i2cdetect`, `i2cget`, etc.)
-
-#### 4. Install Python dependencies (in venv)
 
 
 ```bash
 ```bash
 pip install spidev gpiod smbus2
 pip install spidev gpiod smbus2
@@ -100,9 +94,6 @@ pip install spidev gpiod smbus2
 
 
 - `spidev` — Python SPI bindings (PN5180 NFC reader)
 - `spidev` — Python SPI bindings (PN5180 NFC reader)
 - `gpiod` — Python GPIO bindings via libgpiod (works on both RPi 4 and RPi 5)
 - `gpiod` — Python GPIO bindings via libgpiod (works on both RPi 4 and RPi 5)
-- `smbus2` — Python I2C bindings (NAU7802 scale)
-
-#### 5. Solder all connections
 
 
 Wago connectors or breadboard jumpers are unreliable for SPI — the PN5180
 Wago connectors or breadboard jumpers are unreliable for SPI — the PN5180
 is very sensitive to signal integrity issues (loose connections cause RF
 is very sensitive to signal integrity issues (loose connections cause RF
@@ -150,34 +141,24 @@ Place a tag on the reader. Supported tag types:
 
 
 - SPI speed: **500 kHz** (higher speeds cause communication errors)
 - SPI speed: **500 kHz** (higher speeds cause communication errors)
 - SPI mode: **0** (CPOL=0, CPHA=0)
 - SPI mode: **0** (CPOL=0, CPHA=0)
-- CS timing: **5µs** setup after CS LOW, **100µs** hold after CS HIGH
-- BUSY handshake: wait for BUSY **HIGH** (processing started) then **LOW** (done) — waiting only for LOW is incorrect
-- `setTransceiveMode()`: must write `0x03` to SYSTEM_CONFIG bits 0-2 before every `SEND_DATA`, or the PN5180 buffers data but never transmits on RF
-- Bambu tags use **MIFARE Classic** with per-sector keys derived via **HKDF-SHA256** from a master key + tag UID
-- NTAG reads require **CRC disabled** (unlike MIFARE Classic which needs CRC enabled)
-- The PN5180 handles Crypto1 encryption/decryption internally via the `MFC_AUTHENTICATE` (0x0C) host command
-
----
 
 
-## NAU7802 Scale (I2C)
 
 
 ### Wiring
 ### Wiring
 
 
 | NAU7802 Pin | Raspberry Pi Pin | GPIO   | Wire Color |
 | NAU7802 Pin | Raspberry Pi Pin | GPIO   | Wire Color |
 |-------------|------------------|--------|------------|
 |-------------|------------------|--------|------------|
 | VCC         | Pin 1            | —      | Red        |
 | VCC         | Pin 1            | —      | Red        |
-| SDA         | Pin 27           | GPIO 0 | Yellow     |
-| SCL         | Pin 28           | GPIO 1 | White      |
+| SDA         | Pin 3            | GPIO 2 | Yellow     |
+| SCL         | Pin 5            | GPIO 3 | White      |
 | GND         | Pin 30           | —      | Black      |
 | GND         | Pin 30           | —      | Black      |
 
 
-> **I2C Bus:** Uses I2C bus 0 (GPIO0/GPIO1), enabled via `dtparam=i2c_vc=on`
-> in config.txt. Bus 1 (GPIO2/GPIO3) is the default but those pins are not
-> used here.
+> **I2C Bus:** Uses I2C bus 1 (GPIO2/GPIO3), enabled via `dtparam=i2c_arm=on`
+> in config.txt.
 
 
 ### Verify
 ### Verify
 
 
 ```bash
 ```bash
-sudo i2cdetect -y 0
+sudo i2cdetect -y 1
 # Should show 0x2A
 # Should show 0x2A
 
 
 sudo python3 spoolbuddy/scale_diag.py
 sudo python3 spoolbuddy/scale_diag.py

+ 38 - 0
spoolbuddy/daemon/api_client.py

@@ -69,6 +69,7 @@ class APIClient:
         calibration_factor: float = 1.0,
         calibration_factor: float = 1.0,
         nfc_reader_type: str | None = None,
         nfc_reader_type: str | None = None,
         nfc_connection: str | None = None,
         nfc_connection: str | None = None,
+        backend_url: str | None = None,
         has_backlight: bool = False,
         has_backlight: bool = False,
     ) -> dict | None:
     ) -> dict | None:
         while True:
         while True:
@@ -85,6 +86,7 @@ class APIClient:
                     "calibration_factor": calibration_factor,
                     "calibration_factor": calibration_factor,
                     "nfc_reader_type": nfc_reader_type,
                     "nfc_reader_type": nfc_reader_type,
                     "nfc_connection": nfc_connection,
                     "nfc_connection": nfc_connection,
+                    "backend_url": backend_url,
                     "has_backlight": has_backlight,
                     "has_backlight": has_backlight,
                 },
                 },
             )
             )
@@ -105,6 +107,7 @@ class APIClient:
         firmware_version: str | None = None,
         firmware_version: str | None = None,
         nfc_reader_type: str | None = None,
         nfc_reader_type: str | None = None,
         nfc_connection: str | None = None,
         nfc_connection: str | None = None,
+        backend_url: str | None = None,
     ) -> dict | None:
     ) -> dict | None:
         result = await self._post(
         result = await self._post(
             f"/devices/{device_id}/heartbeat",
             f"/devices/{device_id}/heartbeat",
@@ -116,6 +119,7 @@ class APIClient:
                 "firmware_version": firmware_version,
                 "firmware_version": firmware_version,
                 "nfc_reader_type": nfc_reader_type,
                 "nfc_reader_type": nfc_reader_type,
                 "nfc_connection": nfc_connection,
                 "nfc_connection": nfc_connection,
+                "backend_url": backend_url,
             },
             },
         )
         )
         if result and self._buffer:
         if result and self._buffer:
@@ -188,3 +192,37 @@ class APIClient:
             f"/devices/{device_id}/update-status",
             f"/devices/{device_id}/update-status",
             {"status": status, "message": message},
             {"status": status, "message": message},
         )
         )
+
+    async def diagnostic_result(
+        self,
+        device_id: str,
+        diagnostic: str,
+        success: bool,
+        output: str,
+        exit_code: int,
+    ) -> dict | None:
+        return await self._post(
+            f"/diagnostics/{device_id}/result",
+            {
+                "diagnostic": diagnostic,
+                "success": success,
+                "output": output,
+                "exit_code": exit_code,
+            },
+        )
+
+    async def system_command_result(
+        self,
+        device_id: str,
+        command: str,
+        success: bool,
+        message: str | None = None,
+    ) -> dict | None:
+        return await self._post(
+            f"/devices/{device_id}/system/command-result",
+            {
+                "command": command,
+                "success": success,
+                "message": message,
+            },
+        )

+ 173 - 17
spoolbuddy/daemon/main.py

@@ -3,7 +3,10 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import os
+import shutil
 import socket
 import socket
+import subprocess
 import sys
 import sys
 import time
 import time
 from pathlib import Path
 from pathlib import Path
@@ -26,6 +29,35 @@ logging.basicConfig(
 logger = logging.getLogger("spoolbuddy")
 logger = logging.getLogger("spoolbuddy")
 
 
 
 
+def _spoolbuddy_env_path() -> Path:
+    # installer writes this at <install>/spoolbuddy/.env; allow override for custom setups/tests
+    override = os.environ.get("SPOOLBUDDY_ENV_FILE", "").strip()
+    if override:
+        return Path(override)
+    return Path(__file__).resolve().parent.parent / ".env"
+
+
+def _set_env_value(path: Path, key: str, value: str):
+    lines: list[str] = []
+    if path.exists():
+        lines = path.read_text(encoding="utf-8").splitlines()
+
+    updated = False
+    new_lines: list[str] = []
+    for line in lines:
+        if line.startswith(f"{key}="):
+            new_lines.append(f"{key}={value}")
+            updated = True
+        else:
+            new_lines.append(line)
+
+    if not updated:
+        new_lines.append(f"{key}={value}")
+
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
+
+
 def _get_ip() -> str:
 def _get_ip() -> str:
     try:
     try:
         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -63,14 +95,19 @@ def _deploy_ssh_key(public_key: str) -> None:
 
 
 async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
 async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
     """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
     """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
-    nfc: NFCReader = shared["nfc"]
     display: DisplayControl = shared["display"]
     display: DisplayControl = shared["display"]
-    if not nfc.ok:
-        logger.warning("NFC reader not available, skipping NFC polling")
-        return
 
 
     try:
     try:
         while True:
         while True:
+            if shared.get("nfc_scan_paused", False):
+                await asyncio.sleep(config.nfc_poll_interval)
+                continue
+
+            nfc: NFCReader | None = shared.get("nfc")
+            if not nfc or not nfc.ok:
+                await asyncio.sleep(config.nfc_poll_interval)
+                continue
+
             event_type, event_data = await asyncio.to_thread(nfc.poll)
             event_type, event_data = await asyncio.to_thread(nfc.poll)
 
 
             if event_type == "tag_detected":
             if event_type == "tag_detected":
@@ -90,21 +127,41 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
 
 
             # Check for pending write command
             # Check for pending write command
             pending = shared.get("pending_write")
             pending = shared.get("pending_write")
-            if pending and nfc.state == NFCState.TAG_PRESENT and nfc.current_sak == 0x00:
-                logger.info("Executing pending tag write for spool %d", pending["spool_id"])
-                success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
-                await api.write_tag_result(
-                    device_id=config.device_id,
-                    spool_id=pending["spool_id"],
-                    tag_uid=nfc.current_uid or "",
-                    success=success,
-                    message=msg,
-                )
-                shared.pop("pending_write", None)
+            if pending and nfc.state == NFCState.TAG_PRESENT:
+                if nfc.current_sak == 0x00:
+                    logger.info("Executing pending tag write for spool %d", pending["spool_id"])
+                    success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
+                    await api.write_tag_result(
+                        device_id=config.device_id,
+                        spool_id=pending["spool_id"],
+                        tag_uid=nfc.current_uid or "",
+                        success=success,
+                        message=msg,
+                    )
+                    shared.pop("pending_write", None)
+                else:
+                    # Fail fast when a non-NTAG is presented during write mode.
+                    # Without this, UI can appear stuck on "waiting for SpoolBuddy".
+                    sak = nfc.current_sak
+                    await api.write_tag_result(
+                        device_id=config.device_id,
+                        spool_id=pending["spool_id"],
+                        tag_uid=nfc.current_uid or "",
+                        success=False,
+                        message=f"Incompatible tag type (SAK=0x{sak:02X}). Place an NTAG tag to write.",
+                    )
+                    logger.warning(
+                        "Write aborted for spool %d: incompatible tag type SAK=0x%02X",
+                        pending["spool_id"],
+                        sak,
+                    )
+                    shared.pop("pending_write", None)
 
 
             await asyncio.sleep(config.nfc_poll_interval)
             await asyncio.sleep(config.nfc_poll_interval)
     finally:
     finally:
-        nfc.close()
+        nfc: NFCReader | None = shared.get("nfc")
+        if nfc:
+            nfc.close()
 
 
 
 
 async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
 async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
@@ -166,6 +223,7 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
             firmware_version=__version__,
             firmware_version=__version__,
             nfc_reader_type=nfc.reader_type if nfc else None,
             nfc_reader_type=nfc.reader_type if nfc else None,
             nfc_connection=nfc.connection if nfc else None,
             nfc_connection=nfc.connection if nfc else None,
+            backend_url=config.backend_url,
         )
         )
 
 
         if result:
         if result:
@@ -181,6 +239,103 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
                     logger.warning("Tare command received but scale not available")
                     logger.warning("Tare command received but scale not available")
                 # Skip calibration sync — this heartbeat response predates the tare
                 # Skip calibration sync — this heartbeat response predates the tare
                 continue
                 continue
+            elif cmd == "apply_system_config":
+                payload = result.get("pending_system_payload") or {}
+                backend_url = str(payload.get("backend_url", "")).strip()
+                api_key_value = payload.get("api_key")
+                api_key = str(api_key_value).strip() if api_key_value is not None else ""
+
+                if not backend_url:
+                    await api.system_command_result(
+                        config.device_id,
+                        "apply_system_config",
+                        False,
+                        "Missing backend_url payload",
+                    )
+                    continue
+
+                try:
+                    env_path = _spoolbuddy_env_path()
+                    await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_BACKEND_URL", backend_url)
+                    if api_key:
+                        await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_API_KEY", api_key)
+
+                    await api.system_command_result(
+                        config.device_id,
+                        "apply_system_config",
+                        True,
+                        f"Updated {env_path}",
+                    )
+
+                    logger.info("Applied system config update")
+                except Exception as e:
+                    logger.exception("Failed to apply system config")
+                    await api.system_command_result(
+                        config.device_id,
+                        "apply_system_config",
+                        False,
+                        str(e),
+                    )
+                continue
+            elif cmd in ("run_nfc_diag", "run_scale_diag", "run_read_tag_diag"):
+                if cmd == "run_scale_diag":
+                    diagnostic = "scale"
+                    script_name = "scale_diag.py"
+                elif cmd == "run_read_tag_diag":
+                    diagnostic = "read_tag"
+                    script_name = "read_tag.py"
+                else:
+                    diagnostic = "nfc"
+                    script_name = "pn5180_diag.py"
+                script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name
+
+                if diagnostic in ("nfc", "read_tag"):
+                    logger.info("Pausing NFC continuous scan for diagnostic")
+                    shared["nfc_scan_paused"] = True
+                    nfc_for_diag = shared.get("nfc")
+                    if nfc_for_diag:
+                        await asyncio.to_thread(nfc_for_diag.close)
+                        shared["nfc"] = None
+
+                logger.info("Running %s diagnostic via %s", diagnostic, script_path)
+                try:
+                    proc = await asyncio.to_thread(
+                        subprocess.run,
+                        [sys.executable, str(script_path)],
+                        capture_output=True,
+                        text=True,
+                        timeout=45,
+                    )
+                    output = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "")
+                    await api.diagnostic_result(
+                        config.device_id,
+                        diagnostic,
+                        proc.returncode == 0,
+                        output,
+                        proc.returncode,
+                    )
+                except subprocess.TimeoutExpired:
+                    await api.diagnostic_result(
+                        config.device_id,
+                        diagnostic,
+                        False,
+                        "Diagnostic timed out after 45 seconds",
+                        -1,
+                    )
+                except Exception as e:
+                    await api.diagnostic_result(
+                        config.device_id,
+                        diagnostic,
+                        False,
+                        f"Diagnostic execution failed: {e}",
+                        -1,
+                    )
+                finally:
+                    if diagnostic in ("nfc", "read_tag"):
+                        logger.info("Reinitializing NFC continuous scan after diagnostic")
+                        shared["nfc"] = NFCReader()
+                        shared["nfc_scan_paused"] = False
+                continue
             elif cmd == "write_tag":
             elif cmd == "write_tag":
                 write_payload = result.get("pending_write_payload")
                 write_payload = result.get("pending_write_payload")
                 if write_payload:
                 if write_payload:
@@ -241,6 +396,7 @@ async def main():
         calibration_factor=config.calibration_factor,
         calibration_factor=config.calibration_factor,
         nfc_reader_type=nfc.reader_type,
         nfc_reader_type=nfc.reader_type,
         nfc_connection=nfc.connection,
         nfc_connection=nfc.connection,
+        backend_url=config.backend_url,
         has_backlight=display.has_backlight,
         has_backlight=display.has_backlight,
     )
     )
 
 
@@ -257,7 +413,7 @@ async def main():
 
 
     logger.info("Device registered, starting poll loops")
     logger.info("Device registered, starting poll loops")
 
 
-    shared: dict = {"nfc": nfc, "scale": scale, "display": display}
+    shared: dict = {"nfc": nfc, "scale": scale, "display": display, "nfc_scan_paused": False}
     try:
     try:
         await asyncio.gather(
         await asyncio.gather(
             nfc_poll_loop(config, api, shared),
             nfc_poll_loop(config, api, shared),

+ 15 - 4
spoolbuddy/daemon/nfc_reader.py

@@ -240,13 +240,24 @@ class NFCReader:
 
 
 def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
 def _extract_tray_uuid(blocks: dict[int, bytes]) -> str | None:
     """Extract tray_uuid from Bambu MIFARE Classic data blocks."""
     """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)
+    # Block 4-5 contain the tray UUID as 32 ASCII hex chars across 32 bytes.
     if 4 in blocks and 5 in blocks:
     if 4 in blocks and 5 in blocks:
         raw = blocks[4] + blocks[5]
         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:
         try:
-            uuid_str = uuid_bytes.hex().upper()
+            # Preferred path: decode full ASCII payload, keep only hex chars.
+            ascii_candidate = raw.decode("ascii", errors="ignore")
+            hex_chars = "".join(ch for ch in ascii_candidate if ch in "0123456789abcdefABCDEF")
+            if len(hex_chars) >= 32:
+                uuid_str = hex_chars[:32].upper()
+                if uuid_str != "0" * 32:
+                    return uuid_str
+        except Exception:
+            pass
+
+        try:
+            # Fallback for partially decoded payloads: use first 16 raw bytes as hex.
+            # This preserves compatibility with older decoding behavior.
+            uuid_str = raw[:16].hex().upper()
             if uuid_str and uuid_str != "0" * 32:
             if uuid_str and uuid_str != "0" * 32:
                 return uuid_str
                 return uuid_str
         except Exception:
         except Exception:

+ 7 - 1
spoolbuddy/daemon/scale_reader.py

@@ -25,7 +25,13 @@ class ScaleReader:
             self._scale = NAU7802()
             self._scale = NAU7802()
             self._scale.init()
             self._scale.init()
             self._ok = True
             self._ok = True
-            logger.info("Scale initialized (tare=%d, cal=%.6f)", tare_offset, calibration_factor)
+            bus_num = getattr(self._scale, "_bus_num", "?")
+            logger.info(
+                "Scale initialized on I2C bus %s (tare=%d, cal=%.6f)",
+                bus_num,
+                tare_offset,
+                calibration_factor,
+            )
         except Exception as e:
         except Exception as e:
             logger.info("Scale not available: %s", e)
             logger.info("Scale not available: %s", e)
 
 

+ 246 - 20
spoolbuddy/install/install.sh

@@ -12,6 +12,8 @@
 #
 #
 # Options:
 # Options:
 #   --mode MODE          Installation mode: "spoolbuddy" (companion only) or "full" (both)
 #   --mode MODE          Installation mode: "spoolbuddy" (companion only) or "full" (both)
+#   --repo URL           Git repository URL to install from (default: upstream repo)
+#   --ref REF            Git ref to install (branch/tag/commit, default: main)
 #   --bambuddy-url URL   Bambuddy server URL (required for spoolbuddy mode)
 #   --bambuddy-url URL   Bambuddy server URL (required for spoolbuddy mode)
 #   --api-key KEY        Bambuddy API key (required for spoolbuddy mode)
 #   --api-key KEY        Bambuddy API key (required for spoolbuddy mode)
 #   --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)
 #   --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)
@@ -50,6 +52,10 @@ SPOOLBUDDY_PIP_PACKAGES="spidev gpiod smbus2 httpx"
 
 
 INSTALL_MODE=""          # "spoolbuddy" or "full"
 INSTALL_MODE=""          # "spoolbuddy" or "full"
 INSTALL_PATH=""
 INSTALL_PATH=""
+INSTALL_REPO=""
+INSTALL_REF=""
+DETECTED_INSTALLER_REPO=""
+DETECTED_INSTALLER_REF=""
 BAMBUDDY_URL=""
 BAMBUDDY_URL=""
 API_KEY=""
 API_KEY=""
 BAMBUDDY_PORT="8000"
 BAMBUDDY_PORT="8000"
@@ -188,6 +194,8 @@ show_help() {
     echo ""
     echo ""
     echo "Options:"
     echo "Options:"
     echo "  --mode MODE          \"spoolbuddy\" (companion only) or \"full\" (Bambuddy + SpoolBuddy)"
     echo "  --mode MODE          \"spoolbuddy\" (companion only) or \"full\" (Bambuddy + SpoolBuddy)"
+    echo "  --repo URL           Git repository URL to install from"
+    echo "  --ref REF            Git ref to install (branch/tag/commit)"
     echo "  --bambuddy-url URL   Bambuddy server URL (required for spoolbuddy mode)"
     echo "  --bambuddy-url URL   Bambuddy server URL (required for spoolbuddy mode)"
     echo "  --api-key KEY        Bambuddy API key (required for spoolbuddy mode)"
     echo "  --api-key KEY        Bambuddy API key (required for spoolbuddy mode)"
     echo "  --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)"
     echo "  --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)"
@@ -208,6 +216,70 @@ show_help() {
     exit 0
     exit 0
 }
 }
 
 
+normalize_github_repo_url() {
+    local url="$1"
+    if [[ -z "$url" ]]; then
+        echo ""
+        return
+    fi
+
+    # Convert git@github.com:owner/repo(.git) to https://github.com/owner/repo.git
+    if [[ "$url" =~ ^git@github.com:(.+)$ ]]; then
+        url="https://github.com/${BASH_REMATCH[1]}"
+    fi
+
+    # Keep remote URL style consistent.
+    url="${url%.git}"
+    echo "${url}.git"
+}
+
+detect_installer_source_context() {
+    local script_dir
+    script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+    if git -C "$script_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+        DETECTED_INSTALLER_REF="$(git -C "$script_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
+        local origin_url
+        origin_url="$(git -C "$script_dir" remote get-url origin 2>/dev/null || true)"
+        DETECTED_INSTALLER_REPO="$(normalize_github_repo_url "$origin_url")"
+    fi
+
+    # Optional environment overrides for raw-download installs.
+    if [[ -n "${SPOOLBUDDY_INSTALL_REPO:-}" ]]; then
+        DETECTED_INSTALLER_REPO="$(normalize_github_repo_url "$SPOOLBUDDY_INSTALL_REPO")"
+    fi
+    if [[ -n "${SPOOLBUDDY_INSTALL_REF:-}" ]]; then
+        DETECTED_INSTALLER_REF="$SPOOLBUDDY_INSTALL_REF"
+    fi
+
+    if [[ -z "$INSTALL_REPO" ]]; then
+        if [[ -n "$DETECTED_INSTALLER_REPO" ]]; then
+            INSTALL_REPO="$DETECTED_INSTALLER_REPO"
+        else
+            INSTALL_REPO="$GITHUB_REPO"
+        fi
+    fi
+
+    if [[ -z "$INSTALL_REF" ]]; then
+        if [[ -n "$DETECTED_INSTALLER_REF" && "$DETECTED_INSTALLER_REF" != "HEAD" ]]; then
+            INSTALL_REF="$DETECTED_INSTALLER_REF"
+        else
+            INSTALL_REF="main"
+        fi
+    fi
+}
+
+resolve_install_ref() {
+    local ref="$1"
+    # If ref exists on origin as a branch, track/reset it. Otherwise treat it as tag/commit.
+    if git ls-remote --exit-code --heads origin "$ref" >/dev/null 2>&1; then
+        git checkout -B "$ref" "origin/$ref" > /dev/null 2>&1
+        git reset --hard "origin/$ref" > /dev/null 2>&1
+    else
+        git checkout "$ref" > /dev/null 2>&1
+    fi
+}
+
 # ─────────────────────────────────────────────────────────────────────────────
 # ─────────────────────────────────────────────────────────────────────────────
 # Pre-flight Checks
 # Pre-flight Checks
 # ─────────────────────────────────────────────────────────────────────────────
 # ─────────────────────────────────────────────────────────────────────────────
@@ -312,21 +384,32 @@ configure_boot_config() {
 
 
     if [[ ! -f "$boot_config" ]]; then
     if [[ ! -f "$boot_config" ]]; then
         warn "Boot config not found at /boot/firmware/config.txt or /boot/config.txt"
         warn "Boot config not found at /boot/firmware/config.txt or /boot/config.txt"
-        warn "You may need to manually add: dtparam=i2c_vc=on and dtoverlay=spi0-0cs"
+        warn "You may need to manually add: dtparam=i2c_arm=on and dtoverlay=spi0-0cs"
         return
         return
     fi
     fi
 
 
     info "Configuring $boot_config..."
     info "Configuring $boot_config..."
 
 
-    # Enable I2C bus 0 (GPIO0/GPIO1) for NAU7802 scale
-    if ! grep -q "^dtparam=i2c_vc=on" "$boot_config"; then
+    # Migrate legacy SpoolBuddy setting (bus 0 / i2c_vc) to bus 1 / i2c_arm.
+    if grep -q "^dtparam=i2c_vc=on" "$boot_config"; then
+        sed -i "s/^dtparam=i2c_vc=on$/# dtparam=i2c_vc=on (disabled by SpoolBuddy installer; use i2c_arm bus 1)/" "$boot_config"
+        REBOOT_NEEDED="true"
+        success "Disabled legacy dtparam=i2c_vc=on"
+    fi
+
+    if grep -q "^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)" "$boot_config"; then
+        sed -i "s/^# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0\/GPIO1)$/# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2\/GPIO3)/" "$boot_config"
+    fi
+
+    # Ensure I2C bus 1 (GPIO2/GPIO3) is enabled for NAU7802 scale
+    if ! grep -q "^dtparam=i2c_arm=on" "$boot_config"; then
         echo "" >> "$boot_config"
         echo "" >> "$boot_config"
-        echo "# SpoolBuddy: I2C bus 0 for NAU7802 scale (GPIO0/GPIO1)" >> "$boot_config"
-        echo "dtparam=i2c_vc=on" >> "$boot_config"
+        echo "# SpoolBuddy: I2C bus 1 for NAU7802 scale (GPIO2/GPIO3)" >> "$boot_config"
+        echo "dtparam=i2c_arm=on" >> "$boot_config"
         REBOOT_NEEDED="true"
         REBOOT_NEEDED="true"
-        success "Added dtparam=i2c_vc=on"
+        success "Added dtparam=i2c_arm=on"
     else
     else
-        success "dtparam=i2c_vc=on already set"
+        success "dtparam=i2c_arm=on already set"
     fi
     fi
 
 
     # Disable SPI auto chip-select (manual CS on GPIO23 for PN5180)
     # Disable SPI auto chip-select (manual CS on GPIO23 for PN5180)
@@ -388,11 +471,14 @@ download_spoolbuddy() {
         info "Existing installation found, updating..."
         info "Existing installation found, updating..."
         git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
         git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
         cd "$INSTALL_PATH"
         cd "$INSTALL_PATH"
+        git remote set-url origin "$INSTALL_REPO" 2>/dev/null || true
         run_with_progress "Fetching updates" git fetch origin
         run_with_progress "Fetching updates" git fetch origin
-        git reset --hard origin/main > /dev/null 2>&1
+        resolve_install_ref "$INSTALL_REF"
     else
     else
         mkdir -p "$INSTALL_PATH"
         mkdir -p "$INSTALL_PATH"
-        run_with_progress "Cloning repository" git clone "$GITHUB_REPO" "$INSTALL_PATH"
+        run_with_progress "Cloning repository" git clone "$INSTALL_REPO" "$INSTALL_PATH"
+        cd "$INSTALL_PATH"
+        resolve_install_ref "$INSTALL_REF"
     fi
     fi
 
 
     chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH"
     chown -R "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$INSTALL_PATH"
@@ -422,13 +508,46 @@ SPOOLBUDDY_BACKEND_URL=$BAMBUDDY_URL
 
 
 # API key (create one in Bambuddy Settings -> API Keys)
 # API key (create one in Bambuddy Settings -> API Keys)
 SPOOLBUDDY_API_KEY=$API_KEY
 SPOOLBUDDY_API_KEY=$API_KEY
+
+# NAU7802 scale bus (RPi GPIO2/GPIO3)
+SPOOLBUDDY_I2C_BUS=1
 EOF
 EOF
 
 
     chown "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$env_file"
     chown "$SPOOLBUDDY_SERVICE_USER:$SPOOLBUDDY_SERVICE_USER" "$env_file"
-    chmod 600 "$env_file"
+    # Keep secrets owner-writable while allowing kiosk user (in spoolbuddy group)
+    # to read backend URL/API key for dynamic launcher URL resolution.
+    chgrp "$SPOOLBUDDY_SERVICE_USER" "$env_file"
+    chmod 640 "$env_file"
     success "Configuration saved to $env_file"
     success "Configuration saved to $env_file"
 }
 }
 
 
+ensure_kiosk_env_access() {
+    local env_file="$INSTALL_PATH/spoolbuddy/.env"
+
+    if [[ ! -f "$env_file" ]]; then
+        warn "SpoolBuddy env file not found at $env_file"
+        return
+    fi
+
+    # Ensure kiosk user is known even when this function is called outside setup_kiosk.
+    if [[ -z "$KIOSK_USER" ]]; then
+        KIOSK_USER="${SUDO_USER:-$(logname 2>/dev/null || echo pi)}"
+    fi
+
+    if id "$KIOSK_USER" &>/dev/null; then
+        usermod -aG "$SPOOLBUDDY_SERVICE_USER" "$KIOSK_USER" 2>/dev/null || true
+    fi
+
+    chgrp "$SPOOLBUDDY_SERVICE_USER" "$env_file"
+    chmod 640 "$env_file"
+
+    if ! su -s /bin/sh -c "test -r '$env_file'" "$KIOSK_USER"; then
+        error "Kiosk user '$KIOSK_USER' cannot read $env_file (required for dynamic kiosk URL). Check groups/permissions."
+    fi
+
+    success "Verified kiosk user '$KIOSK_USER' can read SpoolBuddy env"
+}
+
 setup_ssh_key() {
 setup_ssh_key() {
     info "Setting up SSH access for Bambuddy remote updates..."
     info "Setting up SSH access for Bambuddy remote updates..."
 
 
@@ -692,6 +811,15 @@ setup_kiosk() {
     info "Kiosk user: $KIOSK_USER (home: $KIOSK_HOME)"
     info "Kiosk user: $KIOSK_USER (home: $KIOSK_HOME)"
     info "Kiosk URL:  $KIOSK_URL"
     info "Kiosk URL:  $KIOSK_URL"
 
 
+    # Allow kiosk user to read SpoolBuddy env so launcher can resolve backend URL
+    # and API key dynamically instead of using stale install-time fallback values.
+    local spoolbuddy_env="$INSTALL_PATH/spoolbuddy/.env"
+    if [[ -f "$spoolbuddy_env" ]]; then
+        usermod -aG "$SPOOLBUDDY_SERVICE_USER" "$KIOSK_USER" 2>/dev/null || true
+        chgrp "$SPOOLBUDDY_SERVICE_USER" "$spoolbuddy_env" 2>/dev/null || true
+        chmod 640 "$spoolbuddy_env" 2>/dev/null || true
+    fi
+
     # ── Install kiosk packages ────────────────────────────────────────────
     # ── Install kiosk packages ────────────────────────────────────────────
     run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr
     run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr
 
 
@@ -850,19 +978,61 @@ EOF
 </labwc_config>
 </labwc_config>
 EOF
 EOF
 
 
-    # ── labwc autostart ───────────────────────────────────────────────────
-    cat > "$labwc_dir/autostart" << EOF
+        # ── kiosk launcher (dynamic URL from spoolbuddy/.env) ─────────────────
+        local kiosk_launcher="/usr/local/bin/spoolbuddy-kiosk-launch"
+        cat > "$kiosk_launcher" << EOF
+#!/usr/bin/env bash
+set -euo pipefail
+
+ENV_FILE="$INSTALL_PATH/spoolbuddy/.env"
+FALLBACK_URL="$KIOSK_URL"
+
+backend_url=""
+api_key=""
+
+if [[ -r "\$ENV_FILE" ]]; then
+    backend_url="\$(sed -n 's/^SPOOLBUDDY_BACKEND_URL=//p' "\$ENV_FILE" | tail -n1 | tr -d '\r')"
+    api_key="\$(sed -n 's/^SPOOLBUDDY_API_KEY=//p' "\$ENV_FILE" | tail -n1 | tr -d '\r')"
+    backend_url="\${backend_url%\"}"
+    backend_url="\${backend_url#\"}"
+    api_key="\${api_key%\"}"
+    api_key="\${api_key#\"}"
+elif [[ -f "\$ENV_FILE" ]]; then
+    echo "spoolbuddy-kiosk-launch: ERROR: \$ENV_FILE exists but is not readable" >&2
+    echo "spoolbuddy-kiosk-launch: Fix permissions (group-readable by kiosk user) and restart kiosk" >&2
+    exit 1
+fi
+
+if [[ -n "\$backend_url" && -n "\$api_key" ]]; then
+    backend_url="\${backend_url%/}"
+    kiosk_url="\${backend_url}/spoolbuddy?token=\${api_key}"
+else
+    kiosk_url="\$FALLBACK_URL"
+fi
+
+exec chromium --kiosk --no-first-run --disable-infobars \
+    --disable-session-crashed-bubble --disable-features=TranslateUI \
+    --noerrdialogs --disable-component-update \
+    --overscroll-history-navigation=0 \
+    --ozone-platform=wayland \
+    "\$kiosk_url"
+EOF
+
+        chmod 755 "$kiosk_launcher"
+
+        # Tiny self-check: ensure sed command substitutions were not expanded
+        # while generating the launcher script.
+        if ! grep -Fq 'backend_url="$(sed -n' "$kiosk_launcher" || ! grep -Fq 'api_key="$(sed -n' "$kiosk_launcher"; then
+            error "Kiosk launcher generation failed: dynamic env parsing commands were expanded unexpectedly"
+        fi
+
+        # ── labwc autostart ───────────────────────────────────────────────────
+        cat > "$labwc_dir/autostart" << EOF
 # Force 1024x600 (panel doesn't advertise this natively)
 # Force 1024x600 (panel doesn't advertise this natively)
 wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
 wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
 
 
-# Launch Chromium in kiosk mode (virtual keyboard is embedded in the web app)
-chromium --kiosk --no-first-run --disable-infobars \\
-  --disable-session-crashed-bubble --disable-features=TranslateUI \\
-  --noerrdialogs --disable-component-update \\
-  --disk-cache-size=0 \\
-  --overscroll-history-navigation=0 \\
-  --ozone-platform=wayland \\
-  $KIOSK_URL &
+# Launch Chromium via helper that resolves URL from spoolbuddy/.env
+$kiosk_launcher &
 EOF
 EOF
 
 
     chown -R "$KIOSK_USER:$KIOSK_USER" "$labwc_dir"
     chown -R "$KIOSK_USER:$KIOSK_USER" "$labwc_dir"
@@ -897,6 +1067,14 @@ parse_args() {
                 INSTALL_MODE="$2"
                 INSTALL_MODE="$2"
                 shift 2
                 shift 2
                 ;;
                 ;;
+            --repo)
+                INSTALL_REPO="$(normalize_github_repo_url "$2")"
+                shift 2
+                ;;
+            --ref)
+                INSTALL_REF="$2"
+                shift 2
+                ;;
             --bambuddy-url)
             --bambuddy-url)
                 BAMBUDDY_URL="$2"
                 BAMBUDDY_URL="$2"
                 shift 2
                 shift 2
@@ -973,6 +1151,47 @@ gather_config() {
     fi
     fi
     prompt "Installation directory" "$INSTALL_PATH" INSTALL_PATH
     prompt "Installation directory" "$INSTALL_PATH" INSTALL_PATH
 
 
+    if [[ -z "$INSTALL_REPO" ]]; then
+        INSTALL_REPO="$GITHUB_REPO"
+    fi
+    prompt "Git repository URL" "$INSTALL_REPO" INSTALL_REPO
+    INSTALL_REPO="$(normalize_github_repo_url "$INSTALL_REPO")"
+
+    if [[ -z "$INSTALL_REF" ]]; then
+        INSTALL_REF="main"
+    fi
+
+    if [[ "$NON_INTERACTIVE" != "true" && -n "$DETECTED_INSTALLER_REF" && "$DETECTED_INSTALLER_REF" != "HEAD" ]]; then
+        echo ""
+        echo -e "${BOLD}Install Source Ref${NC}"
+        echo "1) main"
+        echo "2) $DETECTED_INSTALLER_REF (detected from installer context)"
+        echo "3) custom"
+        while true; do
+            echo -en "${BOLD}Choose${NC} [1/2/3]: "
+            read -r ref_choice
+            case "$ref_choice" in
+                ""|1)
+                    INSTALL_REF="main"
+                    break
+                    ;;
+                2)
+                    INSTALL_REF="$DETECTED_INSTALLER_REF"
+                    break
+                    ;;
+                3)
+                    prompt "Git ref (branch/tag/commit)" "$INSTALL_REF" INSTALL_REF
+                    break
+                    ;;
+                *)
+                    echo "Please enter 1, 2, or 3."
+                    ;;
+            esac
+        done
+    else
+        prompt "Git ref (branch/tag/commit)" "$INSTALL_REF" INSTALL_REF
+    fi
+
     if [[ "$INSTALL_MODE" == "spoolbuddy" ]]; then
     if [[ "$INSTALL_MODE" == "spoolbuddy" ]]; then
         # Need remote Bambuddy URL and API key
         # Need remote Bambuddy URL and API key
         echo ""
         echo ""
@@ -1010,6 +1229,8 @@ gather_config() {
     echo -e "${CYAN}─────────────────────────────────────────${NC}"
     echo -e "${CYAN}─────────────────────────────────────────${NC}"
     echo -e "  Mode:           ${GREEN}$([ "$INSTALL_MODE" == "full" ] && echo "Bambuddy + SpoolBuddy" || echo "SpoolBuddy only")${NC}"
     echo -e "  Mode:           ${GREEN}$([ "$INSTALL_MODE" == "full" ] && echo "Bambuddy + SpoolBuddy" || echo "SpoolBuddy only")${NC}"
     echo -e "  Install path:   ${GREEN}$INSTALL_PATH${NC}"
     echo -e "  Install path:   ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Git repo:       ${GREEN}$INSTALL_REPO${NC}"
+    echo -e "  Git ref:        ${GREEN}$INSTALL_REF${NC}"
     if [[ "$INSTALL_MODE" == "full" ]]; then
     if [[ "$INSTALL_MODE" == "full" ]]; then
         echo -e "  Bambuddy port:  ${GREEN}$BAMBUDDY_PORT${NC}"
         echo -e "  Bambuddy port:  ${GREEN}$BAMBUDDY_PORT${NC}"
         echo -e "  Bambuddy URL:   ${GREEN}$BAMBUDDY_URL${NC}"
         echo -e "  Bambuddy URL:   ${GREEN}$BAMBUDDY_URL${NC}"
@@ -1030,6 +1251,7 @@ gather_config() {
 
 
 main() {
 main() {
     parse_args "$@"
     parse_args "$@"
+    detect_installer_source_context
 
 
     echo ""
     echo ""
     echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
     echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
@@ -1104,6 +1326,10 @@ main() {
     info "Setting up SpoolBuddy..."
     info "Setting up SpoolBuddy..."
     setup_spoolbuddy_venv
     setup_spoolbuddy_venv
     create_spoolbuddy_env
     create_spoolbuddy_env
+    # Kiosk env access: only needed if actual kiosk hardware is available
+    if [[ -f /boot/firmware/config.txt ]] || [[ -f /boot/config.txt ]]; then
+        ensure_kiosk_env_access
+    fi
     setup_ssh_key
     setup_ssh_key
     create_spoolbuddy_service
     create_spoolbuddy_service
     echo ""
     echo ""

+ 66 - 21
spoolbuddy/scripts/pn5180_diag.py

@@ -10,7 +10,7 @@ Wiring (from spoolbuddy/README.md):
     PN5180 SCK  -> Pi Pin 23 (GPIO11)
     PN5180 SCK  -> Pi Pin 23 (GPIO11)
     PN5180 MISO -> Pi Pin 21 (GPIO9)
     PN5180 MISO -> Pi Pin 21 (GPIO9)
     PN5180 MOSI -> Pi Pin 19 (GPIO10)
     PN5180 MOSI -> Pi Pin 19 (GPIO10)
-    PN5180 NSS  -> Pi Pin 24 (GPIO8 / CE0)
+    PN5180 NSS  -> Pi Pin 16 (GPIO23, manual CS)
     PN5180 BUSY -> Pi Pin 22 (GPIO25)
     PN5180 BUSY -> Pi Pin 22 (GPIO25)
     PN5180 RST  -> Pi Pin 18 (GPIO24)
     PN5180 RST  -> Pi Pin 18 (GPIO24)
 """
 """
@@ -26,6 +26,7 @@ import spidev
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 BUSY_PIN = 25  # Pin 22
 BUSY_PIN = 25  # Pin 22
 RST_PIN = 24  # Pin 18
 RST_PIN = 24  # Pin 18
+NSS_PIN = 23  # Pin 16 (manual CS)
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # SPI command instruction codes (NXP PN5180 datasheet Table 5)
 # SPI command instruction codes (NXP PN5180 datasheet Table 5)
@@ -109,37 +110,67 @@ def _find_gpio_chip():
 class PN5180:
 class PN5180:
     """Low-level driver for the PN5180 NFC frontend over SPI."""
     """Low-level driver for the PN5180 NFC frontend over SPI."""
 
 
-    def __init__(self, spi_bus=0, spi_device=0, spi_speed_hz=1_000_000, busy_pin=BUSY_PIN, rst_pin=RST_PIN):
+    def __init__(
+        self,
+        spi_bus=0,
+        spi_device=0,
+        spi_speed_hz=500_000,
+        busy_pin=BUSY_PIN,
+        rst_pin=RST_PIN,
+        nss_pin=NSS_PIN,
+    ):
         # GPIO setup via libgpiod
         # GPIO setup via libgpiod
         self._chip = _find_gpio_chip()
         self._chip = _find_gpio_chip()
 
 
-        self._busy_line = self._chip.request_lines(
-            consumer="pn5180-diag",
-            config={busy_pin: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT)},
-        )
-        self._rst_line = self._chip.request_lines(
-            consumer="pn5180-diag",
-            config={
-                rst_pin: gpiod.LineSettings(
-                    direction=gpiod.line.Direction.OUTPUT,
-                    output_value=gpiod.line.Value.ACTIVE,
-                )
-            },
-        )
+        try:
+            self._busy_line = self._chip.request_lines(
+                consumer="pn5180-diag",
+                config={busy_pin: gpiod.LineSettings(direction=gpiod.line.Direction.INPUT)},
+            )
+            self._rst_line = self._chip.request_lines(
+                consumer="pn5180-diag",
+                config={
+                    rst_pin: gpiod.LineSettings(
+                        direction=gpiod.line.Direction.OUTPUT,
+                        output_value=gpiod.line.Value.ACTIVE,
+                    )
+                },
+            )
+            self._nss_line = self._chip.request_lines(
+                consumer="pn5180-diag",
+                config={
+                    nss_pin: gpiod.LineSettings(
+                        direction=gpiod.line.Direction.OUTPUT,
+                        output_value=gpiod.line.Value.ACTIVE,
+                    )
+                },
+            )
+        except OSError as e:
+            self._chip.close()
+            if getattr(e, "errno", None) == 16:
+                raise RuntimeError(
+                    "GPIO line is busy (another process owns PN5180 pins). "
+                    "Stop spoolbuddy service before running diagnostics: "
+                    "sudo systemctl stop spoolbuddy"
+                ) from e
+            raise
         self._busy_pin = busy_pin
         self._busy_pin = busy_pin
         self._rst_pin = rst_pin
         self._rst_pin = rst_pin
+        self._nss_pin = nss_pin
 
 
-        # SPI setup – mode 0 (CPOL=0, CPHA=0), MSB first
+        # SPI setup - mode 0 (CPOL=0, CPHA=0), MSB first
         self._spi = spidev.SpiDev()
         self._spi = spidev.SpiDev()
         self._spi.open(spi_bus, spi_device)
         self._spi.open(spi_bus, spi_device)
         self._spi.max_speed_hz = spi_speed_hz
         self._spi.max_speed_hz = spi_speed_hz
         self._spi.mode = 0b00
         self._spi.mode = 0b00
         self._spi.bits_per_word = 8
         self._spi.bits_per_word = 8
+        self._spi.no_cs = True
 
 
     def close(self):
     def close(self):
         self._spi.close()
         self._spi.close()
         self._busy_line.release()
         self._busy_line.release()
         self._rst_line.release()
         self._rst_line.release()
+        self._nss_line.release()
         self._chip.close()
         self._chip.close()
 
 
     # -- low-level helpers --------------------------------------------------
     # -- low-level helpers --------------------------------------------------
@@ -155,6 +186,14 @@ class PN5180:
                 raise TimeoutError("PN5180 BUSY line did not go low")
                 raise TimeoutError("PN5180 BUSY line did not go low")
             time.sleep(0.001)
             time.sleep(0.001)
 
 
+    def _cs_low(self):
+        self._nss_line.set_value(self._nss_pin, gpiod.line.Value.INACTIVE)
+        time.sleep(0.000005)
+
+    def _cs_high(self):
+        self._nss_line.set_value(self._nss_pin, gpiod.line.Value.ACTIVE)
+        time.sleep(0.000100)
+
     def _send_command(self, tx_data, rx_len=0):
     def _send_command(self, tx_data, rx_len=0):
         """Send an SPI command frame and optionally read a response frame.
         """Send an SPI command frame and optionally read a response frame.
 
 
@@ -165,11 +204,13 @@ class PN5180:
         """
         """
         self._wait_busy()
         self._wait_busy()
 
 
-        # Transmit command
+        # Transmit command (manual CS)
+        self._cs_low()
         self._spi.xfer2(list(tx_data))
         self._spi.xfer2(list(tx_data))
+        self._cs_high()
 
 
         if rx_len == 0:
         if rx_len == 0:
-            # Write-only command  wait for processing
+            # Write-only command - wait for processing
             time.sleep(0.001)
             time.sleep(0.001)
             self._wait_busy()
             self._wait_busy()
             return None
             return None
@@ -178,8 +219,10 @@ class PN5180:
         time.sleep(0.001)
         time.sleep(0.001)
         self._wait_busy()
         self._wait_busy()
 
 
-        # Read response
+        # Read response (manual CS)
+        self._cs_low()
         rx = self._spi.xfer2([0xFF] * rx_len)
         rx = self._spi.xfer2([0xFF] * rx_len)
+        self._cs_high()
         time.sleep(0.001)
         time.sleep(0.001)
         self._wait_busy()
         self._wait_busy()
         return bytes(rx)
         return bytes(rx)
@@ -270,8 +313,9 @@ def run_diagnostics():
     print("PN5180 NFC Reader Diagnostics")
     print("PN5180 NFC Reader Diagnostics")
     print("=" * 60)
     print("=" * 60)
 
 
-    nfc = PN5180()
+    nfc = None
     try:
     try:
+        nfc = PN5180()
         # Reset
         # Reset
         print("\n[1] Hardware reset...")
         print("\n[1] Hardware reset...")
         nfc.reset()
         nfc.reset()
@@ -339,7 +383,8 @@ def run_diagnostics():
         print(f"\nERROR: {e}")
         print(f"\nERROR: {e}")
         sys.exit(1)
         sys.exit(1)
     finally:
     finally:
-        nfc.close()
+        if nfc is not None:
+            nfc.close()
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":

+ 50 - 6
spoolbuddy/scripts/read_tag.py

@@ -12,15 +12,30 @@ Key learnings from pico-nfc-bridge.ino:
 
 
 import hashlib
 import hashlib
 import hmac
 import hmac
+import os
 import sys
 import sys
 import time
 import time
 
 
 import gpiod
 import gpiod
 import spidev
 import spidev
 
 
-BUSY_PIN = 25
-RST_PIN = 24
-NSS_PIN = 23  # Manual CS (moved from GPIO8)
+
+def _env_int(name: str, default: int) -> int:
+    value = os.environ.get(name)
+    if value is None or value == "":
+        return default
+    try:
+        return int(value)
+    except ValueError:
+        return default
+
+
+BUSY_PIN = _env_int("SPOOLBUDDY_NFC_BUSY_PIN", 25)
+RST_PIN = _env_int("SPOOLBUDDY_NFC_RST_PIN", 24)
+NSS_PIN = _env_int("SPOOLBUDDY_NFC_NSS_PIN", 23)  # Manual CS by default
+SPI_BUS = _env_int("SPOOLBUDDY_NFC_SPI_BUS", 0)
+SPI_DEVICE = _env_int("SPOOLBUDDY_NFC_SPI_DEVICE", 0)
+SPI_SPEED_HZ = _env_int("SPOOLBUDDY_NFC_SPI_SPEED_HZ", 500_000)
 
 
 # Bambu Lab MIFARE Classic key derivation constants (from pico-nfc-bridge.ino)
 # Bambu Lab MIFARE Classic key derivation constants (from pico-nfc-bridge.ino)
 BAMBU_MASTER_KEY = bytes(
 BAMBU_MASTER_KEY = bytes(
@@ -102,8 +117,8 @@ class PN5180:
             },
             },
         )
         )
         self._spi = spidev.SpiDev()
         self._spi = spidev.SpiDev()
-        self._spi.open(0, 0)
-        self._spi.max_speed_hz = 500_000  # 500kHz like Pico firmware
+        self._spi.open(SPI_BUS, SPI_DEVICE)
+        self._spi.max_speed_hz = SPI_SPEED_HZ
         self._spi.mode = 0b00
         self._spi.mode = 0b00
         self._spi.no_cs = True
         self._spi.no_cs = True
 
 
@@ -547,7 +562,36 @@ def main():
     print("  Supports: Bambu (MIFARE Classic) + NTAG (SpoolEase/OpenPrintTag)")
     print("  Supports: Bambu (MIFARE Classic) + NTAG (SpoolEase/OpenPrintTag)")
     print("=" * 60)
     print("=" * 60)
 
 
-    nfc = PN5180()
+    try:
+        nfc = PN5180()
+    except (OSError, RuntimeError, PermissionError) as e:
+        print(f"\nERROR: Failed to initialize NFC reader: {e}")
+
+        # Check if it's a resource conflict
+        error_str = str(e).lower()
+        is_resource_conflict = any(x in error_str for x in ["busy", "resource", "already in use", "permission denied"])
+
+        if is_resource_conflict:
+            print("\nGPIO/SPI RESOURCE IN USE: Another process is using the NFC reader.")
+            print("This typically means the SpoolBuddy daemon is already reading tags.")
+            print("\nTo run this diagnostic, stop the daemon first:")
+            print("  sudo systemctl stop bambuddy")
+            print("  # Run diagnostic")
+            print("  .../read_tag.py")
+            print("  # Restart daemon when done:")
+            print("  sudo systemctl start bambuddy")
+        else:
+            print("\nCheck:")
+            print("  - Correct GPIO chip is available (/dev/gpiochip0 or /dev/gpiochip4)")
+            print(f"  - SPI device is available (SPI_BUS={SPI_BUS}, SPI_DEVICE={SPI_DEVICE})")
+            print("  - GPIO and SPI permissions are correct")
+            # Only print full traceback for unexpected errors
+            import traceback
+
+            traceback.print_exc()
+
+        sys.exit(1)
+
     try:
     try:
         nfc.reset()
         nfc.reset()
         ver = nfc.read_eeprom(0x10, 2)
         ver = nfc.read_eeprom(0x10, 2)

+ 123 - 28
spoolbuddy/scripts/scale_diag.py

@@ -1,17 +1,29 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-"""NAU7802 Scale Diagnostic  ported from SpoolBuddy Rust firmware.
+"""NAU7802 Scale Diagnostic - ported from SpoolBuddy Rust firmware.
 
 
 I2C address: 0x2A
 I2C address: 0x2A
-Bus: /dev/i2c-0 (GPIO0/GPIO1 on RPi)
+Bus: /dev/i2c-1 (GPIO2/GPIO3 on RPi)
 """
 """
 
 
+import os
 import struct
 import struct
 import sys
 import sys
 import time
 import time
 
 
 import smbus2
 import smbus2
 
 
-I2C_BUS = 0
+
+def _env_int(name: str, default: int) -> int:
+    value = os.environ.get(name)
+    if value is None or value == "":
+        return default
+    try:
+        return int(value)
+    except ValueError:
+        return default
+
+
+I2C_BUS = _env_int("SPOOLBUDDY_I2C_BUS", 1)
 NAU7802_ADDR = 0x2A
 NAU7802_ADDR = 0x2A
 
 
 # Register addresses
 # Register addresses
@@ -39,6 +51,7 @@ PU_AVDDS = 0x80  # AVDD source select
 
 
 class NAU7802:
 class NAU7802:
     def __init__(self, bus=I2C_BUS, addr=NAU7802_ADDR):
     def __init__(self, bus=I2C_BUS, addr=NAU7802_ADDR):
+        self._bus_num = bus
         self._bus = smbus2.SMBus(bus)
         self._bus = smbus2.SMBus(bus)
         self._addr = addr
         self._addr = addr
 
 
@@ -51,20 +64,37 @@ class NAU7802:
     def write_reg(self, reg: int, val: int):
     def write_reg(self, reg: int, val: int):
         self._bus.write_byte_data(self._addr, reg, val & 0xFF)
         self._bus.write_byte_data(self._addr, reg, val & 0xFF)
 
 
+    def _update_bits(self, reg: int, mask: int, value: int):
+        cur = self.read_reg(reg)
+        self.write_reg(reg, (cur & ~mask) | (value & mask))
+
+    def _set_bit(self, reg: int, bit: int, enabled: bool):
+        mask = 1 << bit
+        self._update_bits(reg, mask, mask if enabled else 0)
+
+    def _set_field(self, reg: int, shift: int, width: int, value: int):
+        mask = ((1 << width) - 1) << shift
+        self._update_bits(reg, mask, value << shift)
+
     def init(self):
     def init(self):
-        """Initialize NAU7802 — matches Rust firmware init sequence."""
-        revision = self.read_reg(REG_REVISION)
-        print(f"  Revision: 0x{revision:02X}")
+        """Initialize NAU7802 using the Adafruit library startup sequence."""
 
 
         # Reset
         # Reset
-        self.write_reg(REG_PU_CTRL, PU_RR)
+        self._set_bit(REG_PU_CTRL, 0, True)  # RR=1
         time.sleep(0.010)
         time.sleep(0.010)
-        self.write_reg(REG_PU_CTRL, 0x00)
+        self._set_bit(REG_PU_CTRL, 0, False)  # RR=0
+        self._set_bit(REG_PU_CTRL, 1, True)  # PUD=1
+        time.sleep(0.001)
+
+        # Enable digital + analog and allow analog section to settle.
+        self._set_bit(REG_PU_CTRL, 1, True)  # PUD=1
+        self._set_bit(REG_PU_CTRL, 2, True)  # PUA=1
+        time.sleep(0.600)
 
 
-        # Power up digital + analog
-        self.write_reg(REG_PU_CTRL, PU_PUD | PU_PUA)
+        # Start conversion cycle (PU_CS bit 4) after power-up.
+        self._set_bit(REG_PU_CTRL, 4, True)
 
 
-        # Wait for power-up ready
+        # Wait for power-up ready (PU_PUR bit 3)
         for _ in range(100):
         for _ in range(100):
             status = self.read_reg(REG_PU_CTRL)
             status = self.read_reg(REG_PU_CTRL)
             if status & PU_PUR:
             if status & PU_PUR:
@@ -74,28 +104,33 @@ class NAU7802:
         else:
         else:
             raise TimeoutError("NAU7802 power-up timeout")
             raise TimeoutError("NAU7802 power-up timeout")
 
 
-        # Sample rate: 10 SPS (bits 6:4 of CTRL2 = 0b000)
-        ctrl2 = self.read_reg(REG_CTRL2)
-        self.write_reg(REG_CTRL2, (ctrl2 & 0x8F) | (0 << 4))
-        print("  Sample rate: 10 SPS")
+        # Check revision register low nibble (Adafruit expects 0xF).
+        revision = self.read_reg(REG_REVISION)
+        print(f"  Revision: 0x{revision:02X}")
+        if (revision & 0x0F) != 0x0F:
+            raise RuntimeError(f"Unexpected NAU7802 revision register: 0x{revision:02X}")
+
+        # Internal LDO enable is PU_CTRL.AVDDS (bit 7); set LDO voltage to 3.0V.
+        self._set_bit(REG_PU_CTRL, 7, True)  # AVDDS=1 (internal LDO)
+        self._set_field(REG_CTRL1, shift=3, width=3, value=0b101)  # VLDO=3.0V
+        print("  LDO: 3.0V (internal)")
 
 
         # Gain: 128x (bits 2:0 of CTRL1 = 0b111)
         # Gain: 128x (bits 2:0 of CTRL1 = 0b111)
-        ctrl1 = self.read_reg(REG_CTRL1)
-        self.write_reg(REG_CTRL1, (ctrl1 & 0xF8) | 7)
+        self._set_field(REG_CTRL1, shift=0, width=3, value=0b111)
         print("  Gain: 128x")
         print("  Gain: 128x")
 
 
-        # LDO: 3.3V (bits 5:3 of CTRL1 = 0b100)
-        ctrl1 = self.read_reg(REG_CTRL1)
-        self.write_reg(REG_CTRL1, (ctrl1 & 0xC7) | (0b100 << 3))
+        # Sample rate: 10 SPS (CTRL2 bits 6:4 = 0b000)
+        self._set_field(REG_CTRL2, shift=4, width=3, value=0b000)
+        print("  Sample rate: 10 SPS")
+
+        # Adafruit tuning: disable ADC chopper clock (ADC bits 5:4 = 0b11)
+        self._set_field(REG_ADC, shift=4, width=2, value=0b11)
 
 
-        # Enable internal LDO (bit 7 of CTRL1)
-        ctrl1 = self.read_reg(REG_CTRL1)
-        self.write_reg(REG_CTRL1, ctrl1 | 0x80)
-        print("  LDO: 3.3V (internal)")
+        # Adafruit tuning: use low ESR caps (PGA bit 6 = 0)
+        self._set_bit(REG_PGA, 6, False)
 
 
         # Start conversion cycle
         # Start conversion cycle
-        pu_ctrl = self.read_reg(REG_PU_CTRL)
-        self.write_reg(REG_PU_CTRL, pu_ctrl | PU_CS)
+        self._set_bit(REG_PU_CTRL, 4, True)
         print("  Conversion started")
         print("  Conversion started")
 
 
     def data_ready(self) -> bool:
     def data_ready(self) -> bool:
@@ -119,6 +154,39 @@ def main():
     print("NAU7802 Scale Diagnostic")
     print("NAU7802 Scale Diagnostic")
     print("=" * 60)
     print("=" * 60)
 
 
+    print(f"Configured bus: {I2C_BUS}, address: 0x{NAU7802_ADDR:02X}")
+
+    # Probe both common I2C buses and show where devices are actually visible.
+    found_by_bus: dict[int, list[int]] = {}
+    for bus_num in (0, 1):
+        found_by_bus[bus_num] = []
+        try:
+            with smbus2.SMBus(bus_num) as probe_bus:
+                for addr in range(0x03, 0x78):
+                    try:
+                        probe_bus.read_byte(addr)
+                        found_by_bus[bus_num].append(addr)
+                    except OSError:
+                        continue
+        except FileNotFoundError:
+            continue
+        except PermissionError:
+            continue
+
+    for bus_num, addrs in found_by_bus.items():
+        if addrs:
+            pretty = " ".join(f"0x{a:02X}" for a in addrs)
+            print(f"Bus {bus_num} devices: {pretty}")
+        else:
+            print(f"Bus {bus_num} devices: (none)")
+
+    if NAU7802_ADDR not in found_by_bus.get(I2C_BUS, []):
+        for alt in (1, 0):
+            if alt != I2C_BUS and NAU7802_ADDR in found_by_bus.get(alt, []):
+                print(f"\nHint: NAU7802 (0x{NAU7802_ADDR:02X}) appears on bus {alt}, not configured bus {I2C_BUS}.")
+                print(f"Try: SPOOLBUDDY_I2C_BUS={alt} .../scale_diag.py")
+                break
+
     scale = NAU7802()
     scale = NAU7802()
     try:
     try:
         print("[1] Initializing...")
         print("[1] Initializing...")
@@ -158,9 +226,36 @@ def main():
 
 
     except Exception as e:
     except Exception as e:
         print(f"\nERROR: {e}")
         print(f"\nERROR: {e}")
-        import traceback
+        is_known_error = False
+
+        if isinstance(e, OSError):
+            if e.errno == 16:  # Device or resource busy
+                is_known_error = True
+                print("\nI2C DEVICE BUSY (Errno 16): Another process is using the I2C bus.")
+                print("This typically means the SpoolBuddy daemon is already reading the scale.")
+                print("\nTo run this diagnostic, stop the daemon first:")
+                print("  sudo systemctl stop bambuddy")
+                print("  # Run diagnostic")
+                print("  .../scale_diag.py")
+                print("  # Restart daemon when done:")
+                print("  sudo systemctl start bambuddy")
+            elif e.errno == 121:
+                is_known_error = True
+                print("\nI2C NACK (Errno 121): the device did not acknowledge reads at 0x2A.")
+                print("Check:")
+                print("  - NAU7802 SDA/SCL are on the configured bus pins")
+                print("  - 3.3V and GND are correct and stable")
+                print("  - Sensor address is really 0x2A")
+                print("  - No loose wire or swapped SDA/SCL")
+            else:
+                print(f"\nI2C Error (Errno {e.errno}): {e}")
+
+        # Only print full traceback for unexpected errors
+        if not is_known_error:
+            import traceback
+
+            traceback.print_exc()
 
 
-        traceback.print_exc()
         sys.exit(1)
         sys.exit(1)
     finally:
     finally:
         scale.close()
         scale.close()