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.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__)
 
@@ -1137,6 +1138,22 @@ class LinkTagRequest(BaseModel):
     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)
 async def link_tag_to_spool(
     spool_id: int,
@@ -1152,11 +1169,17 @@ async def link_tag_to_spool(
     if spool.archived_at:
         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
-    if data.tag_uid:
+    if normalized_tag_uid:
         conflict = await db.execute(
             select(Spool).where(
-                Spool.tag_uid == data.tag_uid,
+                func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
             )
@@ -1166,7 +1189,7 @@ async def link_tag_to_spool(
         # Auto-clear from archived spools (tag recycling)
         archived_with_tag = await db.execute(
             select(Spool).where(
-                Spool.tag_uid == data.tag_uid,
+                func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 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():
             old_spool.tag_uid = None
 
-    if data.tray_uuid:
+    if normalized_tray_uuid:
         conflict = await db.execute(
             select(Spool).where(
-                Spool.tray_uuid == data.tray_uuid,
+                func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 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")
         archived_with_uuid = await db.execute(
             select(Spool).where(
-                Spool.tray_uuid == data.tray_uuid,
+                func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 Spool.archived_at.is_not(None),
             )
@@ -1195,9 +1218,9 @@ async def link_tag_to_spool(
             old_spool.tray_uuid = 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:
-        spool.tray_uuid = data.tray_uuid
+        spool.tray_uuid = normalized_tray_uuid
     if data.tag_type is not None:
         spool.tag_type = data.tag_type
     if data.data_origin is not None:

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

@@ -1,8 +1,11 @@
 """SpoolBuddy device management API routes."""
 
 import asyncio
+import json
 import logging
+import time
 from datetime import datetime, timedelta, timezone
+from urllib.parse import urlparse
 
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
@@ -18,12 +21,15 @@ from backend.app.schemas.spoolbuddy import (
     CalibrationResponse,
     DeviceRegisterRequest,
     DeviceResponse,
+    DiagnosticResultRequest,
     DisplaySettingsRequest,
     HeartbeatRequest,
     HeartbeatResponse,
     ScaleReadingRequest,
     SetCalibrationFactorRequest,
     SetTareRequest,
+    SystemCommandResultRequest,
+    SystemConfigRequest,
     TagRemovedRequest,
     TagScannedRequest,
     UpdateSpoolWeightRequest,
@@ -37,6 +43,9 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
 
 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:
@@ -60,6 +69,7 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         calibration_factor=device.calibration_factor,
         nfc_reader_type=device.nfc_reader_type,
         nfc_connection=device.nfc_connection,
+        backend_url=device.backend_url,
         display_brightness=device.display_brightness,
         display_blank_timeout=device.display_blank_timeout,
         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 ---
 
 
@@ -99,6 +122,8 @@ async def register_device(
         device.has_scale = req.has_scale
         device.nfc_reader_type = req.nfc_reader_type
         device.nfc_connection = req.nfc_connection
+        if req.backend_url:
+            device.backend_url = req.backend_url
         device.has_backlight = req.has_backlight
         device.last_seen = now
         # 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_connection=req.nfc_connection,
             has_backlight=req.has_backlight,
+            backend_url=req.backend_url,
             last_seen=now,
         )
         db.add(device)
@@ -127,6 +153,7 @@ async def register_device(
     await db.commit()
     await db.refresh(device)
 
+    _spoolbuddy_online_last_broadcast[device.device_id] = time.time()
     await ws_manager.broadcast(
         {
             "type": "spoolbuddy_online",
@@ -187,25 +214,37 @@ async def device_heartbeat(
         device.nfc_reader_type = req.nfc_reader_type
     if req.nfc_connection:
         device.nfc_connection = req.nfc_connection
+    if req.backend_url:
+        device.backend_url = req.backend_url
 
     # Return and clear pending command
     pending = device.pending_command
     pending_write = None
+    pending_system = None
     if pending == "write_tag" and device.pending_write_payload:
         # Parse the stored JSON payload to include in response
-        import json
-
         try:
             pending_write = json.loads(device.pending_write_payload)
         except (json.JSONDecodeError, TypeError):
             pending_write = None
         # 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:
         device.pending_command = None
 
     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(
             {
                 "type": "spoolbuddy_online",
@@ -213,10 +252,13 @@ async def device_heartbeat(
                 "hostname": device.hostname,
             }
         )
+    if was_offline:
+        logger.info("SpoolBuddy device back online: %s", device.device_id)
 
     return HeartbeatResponse(
         pending_command=pending,
         pending_write_payload=pending_write,
+        pending_system_payload=pending_system,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
         display_brightness=device.display_brightness,
@@ -266,7 +308,15 @@ async def nfc_tag_scanned(
                 "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}
 
@@ -582,6 +632,173 @@ async def update_display_settings(
     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 ---
 
 

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

@@ -1414,6 +1414,16 @@ async def run_migrations(conn):
     except OperationalError:
         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
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     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)
     nfc_reader_type: 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_blank_timeout: Mapped[int] = mapped_column(Integer, default=0)
     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)
     update_status: Mapped[str | None] = mapped_column(String(20), 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)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     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
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
     has_backlight: bool = False
 
 
@@ -31,6 +32,7 @@ class DeviceResponse(BaseModel):
     calibration_factor: float
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
     display_brightness: int = 100
     display_blank_timeout: int = 0
     has_backlight: bool = False
@@ -59,11 +61,13 @@ class HeartbeatRequest(BaseModel):
     ip_address: str | None = None
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
+    backend_url: str | None = None
 
 
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
     pending_write_payload: dict | None = None
+    pending_system_payload: dict | None = None
     tare_offset: int
     calibration_factor: float
     display_brightness: int = 100
@@ -105,10 +109,6 @@ class UpdateSpoolWeightRequest(BaseModel):
 # --- Calibration schemas ---
 
 
-class TareRequest(BaseModel):
-    pass
-
-
 class SetTareRequest(BaseModel):
     tare_offset: int
 
@@ -143,3 +143,24 @@ class WriteTagResultRequest(BaseModel):
 class DisplaySettingsRequest(BaseModel):
     brightness: int = Field(ge=0, le=100)
     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
 
-from sqlalchemy import func, select
+from sqlalchemy import func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.models.spool import Spool
 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__)
 
@@ -18,14 +22,17 @@ ZERO_TRAY_UUID = "00000000000000000000000000000000"
 
 def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
     """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
 
 
 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)."""
-    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)
     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_color = tray_data.get("tray_color", "FFFFFFFF")  # RRGGBBAA
     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", "")
     nozzle_min = tray_data.get("nozzle_temp_min", 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).
     """
+    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)
-    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(
             select(Spool)
             .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)
         )
         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
 
     # 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(
             select(Spool)
             .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)
         )
         spool = result.scalar_one_or_none()
         if 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
 
 

+ 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 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.spoolbuddy_device import SpoolBuddyDevice
 
@@ -154,6 +155,7 @@ class TestDeviceEndpoints:
     @pytest.mark.integration
     async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):
         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:
             mock_ws.broadcast = AsyncMock()
@@ -166,6 +168,10 @@ class TestDeviceEndpoints:
         data = resp.json()
         assert data["tare_offset"] == device.tare_offset
         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.integration
@@ -208,6 +214,7 @@ class TestDeviceEndpoints:
     @pytest.mark.integration
     async def test_heartbeat_broadcasts_online_when_was_offline(self, async_client: AsyncClient, device_factory):
         # Create device with last_seen far in the past (offline)
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast.clear()
         await device_factory(
             device_id="sb-offline",
             last_seen=datetime.now(timezone.utc) - timedelta(seconds=120),
@@ -227,6 +234,55 @@ class TestDeviceEndpoints:
         assert msg["type"] == "spoolbuddy_online"
         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

+ 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
 
 
+@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) ---------------------------
 
 

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

@@ -4996,6 +4996,7 @@ export interface SpoolBuddyDevice {
   device_id: string;
   hostname: string;
   ip_address: string;
+  backend_url?: string | null;
   firmware_version: string | null;
   has_nfc: boolean;
   has_scale: boolean;
@@ -5055,6 +5056,12 @@ export const spoolbuddyApi = {
       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) =>
     request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check`),
 
@@ -5078,6 +5085,18 @@ export const spoolbuddyApi = {
       method: 'POST',
       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 {

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

@@ -24,9 +24,17 @@ interface SpoolFormModalProps {
   spool?: InventorySpool | null;
   printersWithCalibrations?: PrinterWithCalibrations[];
   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 queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -317,6 +325,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         await saveKProfiles(newSpool.id);
       }
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      if (onSpoolsCreated) onSpoolsCreated([newSpool]);
       showToast(t('inventory.spoolCreated'), 'success');
       onClose();
     },
@@ -335,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         }
       }
       await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      if (onSpoolsCreated) onSpoolsCreated(newSpools);
       showToast(t('inventory.spoolsCreated', { count: newSpools.length }), 'success');
       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 { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -37,6 +37,11 @@ export function SpoolBuddyLayout() {
     refetchInterval: 30000,
   });
   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
   const initializedRef = useRef(false);
@@ -69,14 +74,14 @@ export function SpoolBuddyLayout() {
 
   // Update alert based on device state and available updates
   useEffect(() => {
-    if (!sbState.deviceOnline) {
+    if (!effectiveDeviceOnline) {
       setAlert({ type: 'warning', message: 'SpoolBuddy device disconnected' });
     } else if (updateCheck?.update_available && updateCheck.latest_version) {
       setAlert({ type: 'info', message: `Update available: v${updateCheck.latest_version}` });
     } else {
       setAlert(null);
     }
-  }, [sbState.deviceOnline, updateCheck]);
+  }, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
 
   // Track user activity for screen blank
   const resetActivity = useCallback(() => {
@@ -118,12 +123,12 @@ export function SpoolBuddyLayout() {
         <SpoolBuddyTopBar
           selectedPrinterId={selectedPrinterId}
           onPrinterChange={setSelectedPrinterId}
-          deviceOnline={sbState.deviceOnline}
+          deviceOnline={effectiveDeviceOnline}
         />
 
         <main className="flex-1 overflow-y-auto">
           <Outlet context={{
-            selectedPrinterId, setSelectedPrinterId, sbState, setAlert,
+            selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
             displayBrightness, setDisplayBrightness,
             displayBlankTimeout, setDisplayBlankTimeout,
           }} />

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

@@ -4467,6 +4467,19 @@ export default {
       deviceInfo: 'Device Info',
       hostname: 'Host',
       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
       brightness: 'Brightness',
       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'}`} />
         </button>
       </div>
-
-      {/* General Tab */}
       {activeTab === 'general' && (
       <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
         {/* Left Column - General Settings */}

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

@@ -15,6 +15,20 @@ const SPOOL_COLORS = [
   '#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 ---
 function ColorCyclingSpool() {
   const { t } = useTranslation();
@@ -156,9 +170,13 @@ export function SpoolBuddyDashboard() {
 
   // Find spool by tag_id in the loaded spools list
   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;
-    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
   const untaggedSpools = useMemo(() => {
@@ -363,26 +381,26 @@ export function SpoolBuddyDashboard() {
             <div className="flex-1 flex items-center justify-center min-h-0">
               {!sbState.deviceOnline ? (
                 <DeviceOfflineState />
-              ) : displayedSpool && displayedTagId && hiddenTagId !== displayedTagId ? (
+              ) : (displayedSpool || sbState.matchedSpool) && displayedTagId && hiddenTagId !== displayedTagId ? (
                 <SpoolInfoCard
                   spool={{
-                    id: displayedSpool.id,
+                    id: displayedSpool?.id ?? sbState.matchedSpool!.id,
                     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}
                   onSyncWeight={() => refetchSpools()}
                   onAssignToAms={() => setShowAssignAmsModal(true)}
                   onClose={handleCloseSpoolCard}
                 />
-              ) : displayedTagId && !displayedSpool && hiddenTagId !== displayedTagId ? (
+              ) : currentTagId && displayedTagId && !displayedSpool && !sbState.matchedSpool && hiddenTagId !== displayedTagId ? (
                 <UnknownTagCard
                   tagUid={displayedTagId}
                   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 type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 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 {
   if (seconds < 60) return `${seconds}s`;
   if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
@@ -36,6 +40,39 @@ const BLANK_OPTIONS = [
 
 function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
   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 (
     <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-400 font-mono">{device.device_id}</span>
       </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>
   );
 }

+ 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 { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
+import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
 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 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() {
   const { t } = useTranslation();
@@ -19,16 +42,10 @@ export function SpoolBuddyWriteTagPage() {
   const [searchQuery, setSearchQuery] = useState('');
   const [writeStatus, setWriteStatus] = useState<WriteStatus>('idle');
   const [writeMessage, setWriteMessage] = useState('');
+  const [untagging, setUntagging] = useState(false);
   const [tagOnReader, setTagOnReader] = useState(false);
   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({
     queryKey: ['inventory-spools'],
@@ -42,8 +59,14 @@ export function SpoolBuddyWriteTagPage() {
     refetchInterval: 5000,
   });
 
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
   const device = devices[0];
   const deviceOnline = sbState.deviceOnline;
+  const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
 
   // Filter spools based on tab
   const filteredSpools = useMemo(() => {
@@ -51,7 +74,7 @@ export function SpoolBuddyWriteTagPage() {
     if (activeTab === 'existing') {
       list = spools.filter(s => !s.tag_uid && !s.archived_at);
     } 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 {
       return [];
     }
@@ -157,50 +180,60 @@ export function SpoolBuddyWriteTagPage() {
     setWriteMessage('');
   };
 
-  const handleCreateAndSelect = async () => {
-    setCreating(true);
+  const handleUntagSpool = async () => {
+    if (!selectedSpool || !isReplaceTagged(selectedSpool)) return;
+    setUntagging(true);
+    setWriteStatus('idle');
+    setWriteMessage('');
     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 {
-      setWriteMessage(t('spoolbuddy.writeTag.createFailed', 'Failed to create spool'));
       setWriteStatus('error');
+      setWriteMessage(t('spoolbuddy.writeTag.untagFailed', 'Failed to remove tag from spool'));
     } 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 handleOpenNfcDiagnostics = useCallback(() => {
+    setDiagnosticOpen('nfc');
+  }, []);
+
+  const handleOpenScaleDiagnostics = useCallback(() => {
+    setDiagnosticOpen('scale');
+  }, []);
+
   return (
     <div className="flex flex-col h-full">
+      {diagnosticOpen && (
+        <DiagnosticModal
+          type={diagnosticOpen}
+          onClose={() => setDiagnosticOpen(null)}
+          deviceId={device?.device_id || ''}
+        />
+      )}
+
       {/* Tab bar */}
       <div className="flex border-b border-bambu-dark-tertiary shrink-0">
         {([
@@ -227,19 +260,9 @@ export function SpoolBuddyWriteTagPage() {
         {/* Left panel — spool list or form */}
         <div className="flex-1 flex flex-col overflow-hidden border-r border-bambu-dark-tertiary">
           {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}
               t={t}
             />
@@ -295,9 +318,14 @@ export function SpoolBuddyWriteTagPage() {
             deviceOnline={deviceOnline}
             canWrite={!!canWrite}
             isReplace={activeTab === 'replace'}
+            canUntag={activeTab === 'replace' && !!selectedSpool && isReplaceTagged(selectedSpool)}
+            untagging={untagging}
             onWrite={handleWriteTag}
+            onUntag={handleUntagSpool}
             onCancel={handleCancelWrite}
             onRetry={() => { setWriteStatus('idle'); setWriteMessage(''); }}
+            onOpenNfcDiagnostics={handleOpenNfcDiagnostics}
+            onOpenScaleDiagnostics={handleOpenScaleDiagnostics}
             t={t}
           />
         </div>
@@ -306,6 +334,10 @@ export function SpoolBuddyWriteTagPage() {
   );
 }
 
+function isReplaceTagged(spool: InventorySpool): boolean {
+  return !!(spool.tag_uid || spool.tray_uuid);
+}
+
 // --- Spool list item ---
 function SpoolListItem({ spool, selected, showTag, onClick }: {
   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;
   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 (
-    <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>
 
-      {/* 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>
 
-      {/* 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>
 
-      {/* 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>
+        </>
+      )}
 
-      {/* 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>
   );
 }
 
 // --- 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;
   writeMessage: string;
   selectedSpool: InventorySpool | null;
@@ -478,9 +927,14 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
   deviceOnline: boolean;
   canWrite: boolean;
   isReplace: boolean;
+  canUntag: boolean;
+  untagging: boolean;
   onWrite: () => void;
+  onUntag: () => void;
   onCancel: () => void;
   onRetry: () => void;
+  onOpenNfcDiagnostics: () => void;
+  onOpenScaleDiagnostics: () => void;
   t: (key: string, fallback: string) => string;
 }) {
   // Success state
@@ -630,6 +1084,34 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
           ? t('spoolbuddy.writeTag.replaceTag', 'Replace Tag')
           : t('spoolbuddy.writeTag.writeTag', 'Write Tag')}
       </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>
   );
 }

+ 10 - 29
spoolbuddy/README.md

@@ -53,18 +53,15 @@ ls /dev/i2c-*
 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)
 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
-  requirements.
 
 Then reboot:
 
@@ -75,10 +72,10 @@ sudo reboot
 Verify after reboot:
 
 ```bash
-ls /dev/i2c-0
+ls /dev/i2c-1
 # Should exist
 
-sudo i2cdetect -y 0
+sudo i2cdetect -y 1
 # 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
 - `gpiod` — command-line GPIO tools (useful for debugging)
-- `i2c-tools` — I2C diagnostic tools (`i2cdetect`, `i2cget`, etc.)
-
-#### 4. Install Python dependencies (in venv)
 
 ```bash
 pip install spidev gpiod smbus2
@@ -100,9 +94,6 @@ pip install spidev gpiod smbus2
 
 - `spidev` — Python SPI bindings (PN5180 NFC reader)
 - `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
 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 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
 
 | NAU7802 Pin | Raspberry Pi Pin | GPIO   | Wire Color |
 |-------------|------------------|--------|------------|
 | 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      |
 
-> **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
 
 ```bash
-sudo i2cdetect -y 0
+sudo i2cdetect -y 1
 # Should show 0x2A
 
 sudo python3 spoolbuddy/scale_diag.py

+ 38 - 0
spoolbuddy/daemon/api_client.py

@@ -69,6 +69,7 @@ class APIClient:
         calibration_factor: float = 1.0,
         nfc_reader_type: str | None = None,
         nfc_connection: str | None = None,
+        backend_url: str | None = None,
         has_backlight: bool = False,
     ) -> dict | None:
         while True:
@@ -85,6 +86,7 @@ class APIClient:
                     "calibration_factor": calibration_factor,
                     "nfc_reader_type": nfc_reader_type,
                     "nfc_connection": nfc_connection,
+                    "backend_url": backend_url,
                     "has_backlight": has_backlight,
                 },
             )
@@ -105,6 +107,7 @@ class APIClient:
         firmware_version: str | None = None,
         nfc_reader_type: str | None = None,
         nfc_connection: str | None = None,
+        backend_url: str | None = None,
     ) -> dict | None:
         result = await self._post(
             f"/devices/{device_id}/heartbeat",
@@ -116,6 +119,7 @@ class APIClient:
                 "firmware_version": firmware_version,
                 "nfc_reader_type": nfc_reader_type,
                 "nfc_connection": nfc_connection,
+                "backend_url": backend_url,
             },
         )
         if result and self._buffer:
@@ -188,3 +192,37 @@ class APIClient:
             f"/devices/{device_id}/update-status",
             {"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 logging
+import os
+import shutil
 import socket
+import subprocess
 import sys
 import time
 from pathlib import Path
@@ -26,6 +29,35 @@ logging.basicConfig(
 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:
     try:
         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):
     """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
-    nfc: NFCReader = shared["nfc"]
     display: DisplayControl = shared["display"]
-    if not nfc.ok:
-        logger.warning("NFC reader not available, skipping NFC polling")
-        return
 
     try:
         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)
 
             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
             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)
     finally:
-        nfc.close()
+        nfc: NFCReader | None = shared.get("nfc")
+        if nfc:
+            nfc.close()
 
 
 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__,
             nfc_reader_type=nfc.reader_type if nfc else None,
             nfc_connection=nfc.connection if nfc else None,
+            backend_url=config.backend_url,
         )
 
         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")
                 # Skip calibration sync — this heartbeat response predates the tare
                 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":
                 write_payload = result.get("pending_write_payload")
                 if write_payload:
@@ -241,6 +396,7 @@ async def main():
         calibration_factor=config.calibration_factor,
         nfc_reader_type=nfc.reader_type,
         nfc_connection=nfc.connection,
+        backend_url=config.backend_url,
         has_backlight=display.has_backlight,
     )
 
@@ -257,7 +413,7 @@ async def main():
 
     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:
         await asyncio.gather(
             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:
     """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:
         raw = blocks[4] + blocks[5]
-        # UUID is stored as ASCII hex in the first 16 bytes of blocks 4-5
-        uuid_bytes = raw[:16]
         try:
-            uuid_str = uuid_bytes.hex().upper()
+            # 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:
                 return uuid_str
         except Exception:

+ 7 - 1
spoolbuddy/daemon/scale_reader.py

@@ -25,7 +25,13 @@ class ScaleReader:
             self._scale = NAU7802()
             self._scale.init()
             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:
             logger.info("Scale not available: %s", e)
 

+ 246 - 20
spoolbuddy/install/install.sh

@@ -12,6 +12,8 @@
 #
 # Options:
 #   --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)
 #   --api-key KEY        Bambuddy API key (required for spoolbuddy mode)
 #   --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_PATH=""
+INSTALL_REPO=""
+INSTALL_REF=""
+DETECTED_INSTALLER_REPO=""
+DETECTED_INSTALLER_REF=""
 BAMBUDDY_URL=""
 API_KEY=""
 BAMBUDDY_PORT="8000"
@@ -188,6 +194,8 @@ show_help() {
     echo ""
     echo "Options:"
     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 "  --api-key KEY        Bambuddy API key (required for spoolbuddy mode)"
     echo "  --path PATH          Installation directory (default: /opt/spoolbuddy or /opt/bambuddy)"
@@ -208,6 +216,70 @@ show_help() {
     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
 # ─────────────────────────────────────────────────────────────────────────────
@@ -312,21 +384,32 @@ configure_boot_config() {
 
     if [[ ! -f "$boot_config" ]]; then
         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
     fi
 
     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 "# 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"
-        success "Added dtparam=i2c_vc=on"
+        success "Added dtparam=i2c_arm=on"
     else
-        success "dtparam=i2c_vc=on already set"
+        success "dtparam=i2c_arm=on already set"
     fi
 
     # Disable SPI auto chip-select (manual CS on GPIO23 for PN5180)
@@ -388,11 +471,14 @@ download_spoolbuddy() {
         info "Existing installation found, updating..."
         git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
         cd "$INSTALL_PATH"
+        git remote set-url origin "$INSTALL_REPO" 2>/dev/null || true
         run_with_progress "Fetching updates" git fetch origin
-        git reset --hard origin/main > /dev/null 2>&1
+        resolve_install_ref "$INSTALL_REF"
     else
         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
 
     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)
 SPOOLBUDDY_API_KEY=$API_KEY
+
+# NAU7802 scale bus (RPi GPIO2/GPIO3)
+SPOOLBUDDY_I2C_BUS=1
 EOF
 
     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"
 }
 
+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() {
     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 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 ────────────────────────────────────────────
     run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr
 
@@ -850,19 +978,61 @@ EOF
 </labwc_config>
 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)
 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
 
     chown -R "$KIOSK_USER:$KIOSK_USER" "$labwc_dir"
@@ -897,6 +1067,14 @@ parse_args() {
                 INSTALL_MODE="$2"
                 shift 2
                 ;;
+            --repo)
+                INSTALL_REPO="$(normalize_github_repo_url "$2")"
+                shift 2
+                ;;
+            --ref)
+                INSTALL_REF="$2"
+                shift 2
+                ;;
             --bambuddy-url)
                 BAMBUDDY_URL="$2"
                 shift 2
@@ -973,6 +1151,47 @@ gather_config() {
     fi
     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
         # Need remote Bambuddy URL and API key
         echo ""
@@ -1010,6 +1229,8 @@ gather_config() {
     echo -e "${CYAN}─────────────────────────────────────────${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 "  Git repo:       ${GREEN}$INSTALL_REPO${NC}"
+    echo -e "  Git ref:        ${GREEN}$INSTALL_REF${NC}"
     if [[ "$INSTALL_MODE" == "full" ]]; then
         echo -e "  Bambuddy port:  ${GREEN}$BAMBUDDY_PORT${NC}"
         echo -e "  Bambuddy URL:   ${GREEN}$BAMBUDDY_URL${NC}"
@@ -1030,6 +1251,7 @@ gather_config() {
 
 main() {
     parse_args "$@"
+    detect_installer_source_context
 
     echo ""
     echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
@@ -1104,6 +1326,10 @@ main() {
     info "Setting up SpoolBuddy..."
     setup_spoolbuddy_venv
     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
     create_spoolbuddy_service
     echo ""

+ 66 - 21
spoolbuddy/scripts/pn5180_diag.py

@@ -10,7 +10,7 @@ Wiring (from spoolbuddy/README.md):
     PN5180 SCK  -> Pi Pin 23 (GPIO11)
     PN5180 MISO -> Pi Pin 21 (GPIO9)
     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 RST  -> Pi Pin 18 (GPIO24)
 """
@@ -26,6 +26,7 @@ import spidev
 # ---------------------------------------------------------------------------
 BUSY_PIN = 25  # Pin 22
 RST_PIN = 24  # Pin 18
+NSS_PIN = 23  # Pin 16 (manual CS)
 
 # ---------------------------------------------------------------------------
 # SPI command instruction codes (NXP PN5180 datasheet Table 5)
@@ -109,37 +110,67 @@ def _find_gpio_chip():
 class PN5180:
     """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
         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._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.open(spi_bus, spi_device)
         self._spi.max_speed_hz = spi_speed_hz
         self._spi.mode = 0b00
         self._spi.bits_per_word = 8
+        self._spi.no_cs = True
 
     def close(self):
         self._spi.close()
         self._busy_line.release()
         self._rst_line.release()
+        self._nss_line.release()
         self._chip.close()
 
     # -- low-level helpers --------------------------------------------------
@@ -155,6 +186,14 @@ class PN5180:
                 raise TimeoutError("PN5180 BUSY line did not go low")
             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):
         """Send an SPI command frame and optionally read a response frame.
 
@@ -165,11 +204,13 @@ class PN5180:
         """
         self._wait_busy()
 
-        # Transmit command
+        # Transmit command (manual CS)
+        self._cs_low()
         self._spi.xfer2(list(tx_data))
+        self._cs_high()
 
         if rx_len == 0:
-            # Write-only command  wait for processing
+            # Write-only command - wait for processing
             time.sleep(0.001)
             self._wait_busy()
             return None
@@ -178,8 +219,10 @@ class PN5180:
         time.sleep(0.001)
         self._wait_busy()
 
-        # Read response
+        # Read response (manual CS)
+        self._cs_low()
         rx = self._spi.xfer2([0xFF] * rx_len)
+        self._cs_high()
         time.sleep(0.001)
         self._wait_busy()
         return bytes(rx)
@@ -270,8 +313,9 @@ def run_diagnostics():
     print("PN5180 NFC Reader Diagnostics")
     print("=" * 60)
 
-    nfc = PN5180()
+    nfc = None
     try:
+        nfc = PN5180()
         # Reset
         print("\n[1] Hardware reset...")
         nfc.reset()
@@ -339,7 +383,8 @@ def run_diagnostics():
         print(f"\nERROR: {e}")
         sys.exit(1)
     finally:
-        nfc.close()
+        if nfc is not None:
+            nfc.close()
 
 
 if __name__ == "__main__":

+ 50 - 6
spoolbuddy/scripts/read_tag.py

@@ -12,15 +12,30 @@ Key learnings from pico-nfc-bridge.ino:
 
 import hashlib
 import hmac
+import os
 import sys
 import time
 
 import gpiod
 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_MASTER_KEY = bytes(
@@ -102,8 +117,8 @@ class PN5180:
             },
         )
         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.no_cs = True
 
@@ -547,7 +562,36 @@ def main():
     print("  Supports: Bambu (MIFARE Classic) + NTAG (SpoolEase/OpenPrintTag)")
     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:
         nfc.reset()
         ver = nfc.read_eeprom(0x10, 2)

+ 123 - 28
spoolbuddy/scripts/scale_diag.py

@@ -1,17 +1,29 @@
 #!/usr/bin/env python3
-"""NAU7802 Scale Diagnostic  ported from SpoolBuddy Rust firmware.
+"""NAU7802 Scale Diagnostic - ported from SpoolBuddy Rust firmware.
 
 I2C address: 0x2A
-Bus: /dev/i2c-0 (GPIO0/GPIO1 on RPi)
+Bus: /dev/i2c-1 (GPIO2/GPIO3 on RPi)
 """
 
+import os
 import struct
 import sys
 import time
 
 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
 
 # Register addresses
@@ -39,6 +51,7 @@ PU_AVDDS = 0x80  # AVDD source select
 
 class NAU7802:
     def __init__(self, bus=I2C_BUS, addr=NAU7802_ADDR):
+        self._bus_num = bus
         self._bus = smbus2.SMBus(bus)
         self._addr = addr
 
@@ -51,20 +64,37 @@ class NAU7802:
     def write_reg(self, reg: int, val: int):
         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):
-        """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
-        self.write_reg(REG_PU_CTRL, PU_RR)
+        self._set_bit(REG_PU_CTRL, 0, True)  # RR=1
         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):
             status = self.read_reg(REG_PU_CTRL)
             if status & PU_PUR:
@@ -74,28 +104,33 @@ class NAU7802:
         else:
             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)
-        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")
 
-        # 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
-        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")
 
     def data_ready(self) -> bool:
@@ -119,6 +154,39 @@ def main():
     print("NAU7802 Scale Diagnostic")
     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()
     try:
         print("[1] Initializing...")
@@ -158,9 +226,36 @@ def main():
 
     except Exception as 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)
     finally:
         scale.close()