Browse Source

Add SpoolBuddy tag detection modal and fix NFC reader silent failure

  Frontend: Replace inline SpoolInfoCard/UnknownTagCard with full-screen
  TagDetectedModal that auto-opens on NFC tag detection. Known spools show
  remaining weight, fill bar, and offer "Assign to AMS" (new sub-modal with
  printer selector + AMS slot grid) and "Sync Weight". Unknown tags offer
  "Add to Inventory" and "Link to Spool". Modal stays open on tag removal,
  won't re-open for dismissed tags, reopens on re-place after removal.

  Daemon: Fix PN5180 NFC reader silently stopping tag detection when the
  reader drifts into a stuck state. Add auto-recovery (full hardware reset
  after 10 consecutive errors), preventive RF cycling every 60s when idle,
  and periodic status logging. Heartbeat now reports actual nfc_ok/scale_ok
  from reader instances instead of hardcoded True.
maziggy 2 months ago
parent
commit
7767851975

+ 2 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Touch-Friendly UI** — Enlarged all interactive elements across the SpoolBuddy kiosk UI for comfortable finger use on the 1024×600 RPi touchscreen. Bottom nav icons and labels increased (20→24px icons, 10→12px labels, 48→56px bar height). Top bar printer selector and clock enlarged. Dashboard stats bar compacted, printers card removed (printer selection via top bar is sufficient), section headers and device status text bumped up. AMS page single-slot cards, spool visualizations, and fill bars enlarged. AMS unit cards get larger spool previews (56→64px), bigger material/slot text, and larger humidity/temperature indicators. Inventory spool cards, settings page headers, and calibration inputs all sized up to meet 44px minimum tap targets. The AMS slot configuration modal now renders in a two-column full-screen layout on the kiosk display (filament list on left, K-profile and color picker on right) instead of the standard centered dialog, eliminating scrolling.
 
 ### New Features
+- **SpoolBuddy Tag Detection Modal** — Placing an NFC-tagged spool on the SpoolBuddy reader now opens a full-screen modal overlay instead of showing spool info inline in the dashboard's right panel. Known spools display a SpoolIcon with color/brand/material, a large remaining-weight readout with fill bar, and a weight comparison grid. Action buttons: "Assign to AMS" (opens a sub-modal with printer selector and AMS slot grid for one-tap slot assignment via MQTT), "Sync Weight" (pushes scale reading to inventory), and "Close". Unknown tags show the tag UID, scale weight, and offer "Add to Inventory" or "Link to Spool" actions. The modal stays open if the tag is removed (for continued interaction) and won't re-open for the same tag after dismissal — but re-placing a tag after removal reopens it. The inline `SpoolInfoCard` and `UnknownTagCard` are removed from the right panel, which now always shows the idle spool animation.
 - **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
 - **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and a compact printers list with live status indicators; right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
@@ -16,6 +17,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Windows Install Fails With "Syntax of the Command Is Incorrect"** ([#544](https://github.com/maziggy/bambuddy/issues/544)) — The `start_bambuddy.bat` launcher had Unix (LF) line endings instead of Windows (CRLF). When a user's git config has `core.autocrlf=false` or `input`, the file is checked out with LF endings and `cmd.exe` cannot parse it. Added a `.gitattributes` file that forces CRLF for all `.bat` files regardless of git config.
 - **Queue Badge Shows on Incompatible Printers** ([#486](https://github.com/maziggy/bambuddy/issues/486)) — The purple queue counter badge in the printer card header showed on all printers of the same model when a job was scheduled for "any [model]", even if the printer didn't have the matching filament color loaded. The `PrinterQueueWidget` (which shows "Clear Plate & Start") already filtered by filament type and color, but the badge count used the raw unfiltered queue length. Now applies the same filament compatibility filter to the badge count.
 - **SpoolBuddy Daemon Can't Find Hardware Drivers** — The daemon's `nfc_reader.py` and `scale_reader.py` import `read_tag` and `scale_diag` as bare modules, but these files live in `spoolbuddy/scripts/` which isn't on Python's module search path. The systemd service sets `WorkingDirectory` to `spoolbuddy/` and runs `python -m daemon.main`, so only the `spoolbuddy/` and `daemon/` directories are on `sys.path`. Added `scripts/` to `sys.path` at daemon startup, resolved relative to the module file so it works regardless of install path. Also moved the `read_tag` import inside `NFCReader.__init__`'s try/except block — it was previously outside, so a missing module crashed the entire daemon instead of gracefully skipping NFC polling. Demoted hardware-not-available log messages from ERROR to INFO since missing modules are expected when hardware isn't connected.
+- **SpoolBuddy NFC Reader Silently Stops Detecting Tags** — The PN5180 NFC reader could drift into a stuck state where `activate_type_a()` silently returned `None` on every poll, indistinguishable from "no tag present". The daemon continued running with no errors logged (poll failures were at DEBUG level), making the problem invisible. The heartbeat also always reported `nfc_ok=True` regardless of actual reader health. Added auto-recovery: after 10 consecutive poll errors the reader performs a full hardware reset (SPI reset + RF re-init). A preventive RF off/on cycle runs every 60 seconds when idle to prevent reader drift. Poll errors are now logged at WARNING level on first occurrence, and a periodic status line logs every 60 seconds showing poll count, error count, and state. The heartbeat now reports actual `nfc.ok` and `scale.ok` status from the reader instances instead of hardcoded `True`.
 
 ### Improved
 - **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g. Previously every 1-second report interval sent a reading regardless of change, and stability state flips (stable ↔ unstable) also triggered reports — when ADC noise kept the spread hovering around the 2g stability threshold, the flag toggled every cycle, forcing a report with a slightly different weight each time. Removed stability flipping as a report trigger (the stable flag is still included in each report for consumers). Also increased the NAU7802 moving average window from 5 to 20 samples (500ms → 2s) to smooth ADC noise. The frontend also applies a 3g display threshold as defense-in-depth.

+ 293 - 0
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -0,0 +1,293 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { X, Check, Loader2 } from 'lucide-react';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+import { api, type AMSUnit, type AMSTray } from '../../api/client';
+
+function getAmsName(id: number): string {
+  if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
+  if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
+  return `AMS ${id}`;
+}
+
+function trayColorToCSS(color: string | null): string {
+  if (!color) return '#808080';
+  return `#${color.slice(0, 6)}`;
+}
+
+function isTrayEmpty(tray: AMSTray): boolean {
+  return !tray.tray_type || tray.tray_type === '';
+}
+
+interface AssignToAmsModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  spool: MatchedSpool;
+  printerId: number | null;
+}
+
+export function AssignToAmsModal({ isOpen, onClose, spool, printerId }: AssignToAmsModalProps) {
+  const { t } = useTranslation();
+  const [selectedPrinter, setSelectedPrinter] = useState<number | null>(printerId);
+  const [selectedSlot, setSelectedSlot] = useState<{ amsId: number; trayId: number } | null>(null);
+  const [showSuccess, setShowSuccess] = useState(false);
+
+  // Reset state when modal opens
+  useEffect(() => {
+    if (isOpen) {
+      setSelectedPrinter(printerId);
+      setSelectedSlot(null);
+      setShowSuccess(false);
+    }
+  }, [isOpen, printerId]);
+
+  // Escape key handler
+  const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    if (e.key === 'Escape') onClose();
+  }, [onClose]);
+
+  useEffect(() => {
+    if (isOpen) {
+      document.addEventListener('keydown', handleKeyDown);
+    }
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown);
+    };
+  }, [isOpen, handleKeyDown]);
+
+  // Fetch printers
+  const { data: printers = [] } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+    enabled: isOpen,
+  });
+
+  // Fetch printer status
+  const { data: printerStatus } = useQuery({
+    queryKey: ['printerStatus', selectedPrinter],
+    queryFn: () => api.getPrinterStatus(selectedPrinter!),
+    enabled: isOpen && selectedPrinter !== null,
+    refetchInterval: 5000,
+  });
+
+  // Assignment mutation
+  const assignMutation = useMutation({
+    mutationFn: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
+      api.assignSpool(data),
+    onSuccess: () => {
+      setShowSuccess(true);
+      setTimeout(() => {
+        onClose();
+      }, 1500);
+    },
+  });
+
+  if (!isOpen) return null;
+
+  const handleAssign = () => {
+    if (!selectedPrinter || !selectedSlot) return;
+    assignMutation.mutate({
+      spool_id: spool.id,
+      printer_id: selectedPrinter,
+      ams_id: selectedSlot.amsId,
+      tray_id: selectedSlot.trayId,
+    });
+  };
+
+  const amsUnits: AMSUnit[] = printerStatus?.ams ?? [];
+  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+
+  return (
+    <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in" onClick={onClose}>
+      <div
+        className="bg-zinc-800 rounded-2xl shadow-2xl w-full max-w-xl mx-4 animate-slide-up"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="p-6">
+          {/* Header */}
+          <div className="flex items-center justify-between mb-5">
+            <h2 className="text-lg font-semibold text-zinc-100">
+              {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
+            </h2>
+            <button onClick={onClose} className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors">
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Spool summary */}
+          <div className="flex items-center gap-3 p-3 bg-zinc-900/50 rounded-lg mb-5">
+            <div className="w-8 h-8 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
+            <div className="flex-1 min-w-0">
+              <span className="text-sm font-medium text-zinc-200 truncate block">
+                {spool.color_name || 'Unknown'} &bull; {spool.material}
+                {spool.subtype && ` ${spool.subtype}`}
+              </span>
+              <span className="text-xs text-zinc-500">{spool.brand}</span>
+            </div>
+          </div>
+
+          {/* Printer selector */}
+          {printers.length > 1 && (
+            <div className="mb-4">
+              <select
+                value={selectedPrinter ?? ''}
+                onChange={(e) => {
+                  setSelectedPrinter(e.target.value ? Number(e.target.value) : null);
+                  setSelectedSlot(null);
+                }}
+                className="w-full px-3 py-2.5 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-zinc-200 min-h-[44px]"
+              >
+                <option value="">{t('spoolbuddy.modal.noPrinterSelected', 'Select a printer...')}</option>
+                {printers.map((p) => (
+                  <option key={p.id} value={p.id}>{p.name}</option>
+                ))}
+              </select>
+            </div>
+          )}
+
+          {/* AMS slot grid */}
+          {selectedPrinter === null ? (
+            <div className="text-center py-8 text-zinc-500 text-sm">
+              {t('spoolbuddy.modal.noPrinterSelected', 'Select a printer...')}
+            </div>
+          ) : amsUnits.length === 0 ? (
+            <div className="text-center py-8 text-zinc-500 text-sm">
+              {t('spoolbuddy.modal.noAmsDetected', 'No AMS detected on this printer')}
+            </div>
+          ) : (
+            <div className="space-y-3 max-h-[300px] overflow-y-auto">
+              {amsUnits.map((unit) => (
+                <AmsSlotSelector
+                  key={unit.id}
+                  unit={unit}
+                  selectedSlot={selectedSlot}
+                  onSelectSlot={(trayId) => setSelectedSlot({ amsId: unit.id, trayId })}
+                />
+              ))}
+            </div>
+          )}
+
+          {/* Error message */}
+          {assignMutation.isError && (
+            <div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-400">
+              {t('spoolbuddy.modal.assignError', 'Failed to assign spool. Please try again.')}
+            </div>
+          )}
+
+          {/* Action buttons */}
+          <div className="flex gap-3 mt-5">
+            <button
+              onClick={handleAssign}
+              disabled={!selectedSlot || assignMutation.isPending || showSuccess}
+              className={`flex-1 px-5 py-3 rounded-xl text-sm font-medium transition-colors min-h-[44px] ${
+                showSuccess
+                  ? 'bg-green-600/20 text-green-400'
+                  : 'bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 disabled:cursor-not-allowed'
+              }`}
+            >
+              {assignMutation.isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin inline-block mr-1.5" />
+                  {t('spoolbuddy.modal.assigning', 'Assigning...')}
+                </>
+              ) : showSuccess ? (
+                <>
+                  <Check className="w-4 h-4 inline-block mr-1.5" />
+                  {t('spoolbuddy.modal.assignSuccess', 'Assigned!')}
+                </>
+              ) : (
+                t('spoolbuddy.modal.assign', 'Assign')
+              )}
+            </button>
+            <button
+              onClick={onClose}
+              className="px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+            >
+              {t('spoolbuddy.dashboard.close', 'Close')}
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// --- AMS Unit slot selector ---
+
+interface AmsSlotSelectorProps {
+  unit: AMSUnit;
+  selectedSlot: { amsId: number; trayId: number } | null;
+  onSelectSlot: (trayId: number) => void;
+}
+
+function AmsSlotSelector({ unit, selectedSlot, onSelectSlot }: AmsSlotSelectorProps) {
+  const { t } = useTranslation();
+  const trays = unit.tray || [];
+  const isHt = unit.is_ams_ht;
+  const slotCount = isHt ? 1 : 4;
+
+  return (
+    <div className="bg-zinc-900/50 rounded-lg p-3">
+      <div className="text-xs font-medium text-zinc-400 uppercase tracking-wide mb-2">
+        {getAmsName(unit.id)}
+      </div>
+      <div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px]' : 'grid-cols-4'} gap-2`}>
+        {Array.from({ length: slotCount }).map((_, i) => {
+          const tray: AMSTray = trays[i] || {
+            id: i,
+            tray_color: null,
+            tray_type: '',
+            tray_sub_brands: null,
+            tray_id_name: null,
+            tray_info_idx: null,
+            remain: -1,
+            k: null,
+            cali_idx: null,
+            tag_uid: null,
+            tray_uuid: null,
+            nozzle_temp_min: null,
+            nozzle_temp_max: null,
+          };
+          const isEmpty = isTrayEmpty(tray);
+          const color = trayColorToCSS(tray.tray_color);
+          const isSelected = selectedSlot?.amsId === unit.id && selectedSlot.trayId === i;
+
+          return (
+            <button
+              key={i}
+              type="button"
+              onClick={() => onSelectSlot(i)}
+              className={`relative flex flex-col items-center p-2.5 rounded-lg transition-all min-h-[44px] ${
+                isSelected
+                  ? 'ring-2 ring-green-500 bg-green-500/10'
+                  : 'hover:bg-white/5'
+              }`}
+            >
+              {/* Color circle */}
+              <div className="relative w-10 h-10 mb-1">
+                {isEmpty ? (
+                  <div className="w-full h-full rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center">
+                    <div className="w-2 h-2 rounded-full bg-zinc-600" />
+                  </div>
+                ) : (
+                  <div className="w-full h-full rounded-full" style={{ backgroundColor: color }} />
+                )}
+              </div>
+
+              {/* Material */}
+              <span className="text-xs text-zinc-400 truncate max-w-full">
+                {isEmpty ? t('spoolbuddy.ams.empty', 'Empty') : tray.tray_type || '?'}
+              </span>
+
+              {/* Slot number */}
+              <span className="absolute top-0.5 right-1 text-[10px] text-zinc-600">
+                {t('spoolbuddy.modal.slot', 'Slot')} {i + 1}
+              </span>
+            </button>
+          );
+        })}
+      </div>
+    </div>
+  );
+}

+ 362 - 0
frontend/src/components/spoolbuddy/TagDetectedModal.tsx

@@ -0,0 +1,362 @@
+import { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Check, RefreshCw, AlertTriangle, X } from 'lucide-react';
+import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
+import { spoolbuddyApi } from '../../api/client';
+import { SpoolIcon } from './SpoolIcon';
+
+// Storage key for default core weight (shared with SpoolInfoCard)
+const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
+
+function getDefaultCoreWeight(): number {
+  try {
+    const stored = localStorage.getItem(DEFAULT_CORE_WEIGHT_KEY);
+    if (stored) {
+      const weight = parseInt(stored, 10);
+      if (weight >= 0 && weight <= 500) return weight;
+    }
+  } catch {
+    // Ignore errors
+  }
+  return 250;
+}
+
+interface TagDetectedModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  spool: MatchedSpool | null;
+  tagUid: string | null;
+  scaleWeight: number | null;
+  weightStable: boolean;
+  onSyncWeight: () => void;
+  onAssignToAms: () => void;
+  onLinkSpool?: () => void;
+  onAddToInventory: () => void;
+}
+
+export function TagDetectedModal({
+  isOpen,
+  onClose,
+  spool,
+  tagUid,
+  scaleWeight,
+  weightStable,
+  onSyncWeight,
+  onAssignToAms,
+  onLinkSpool,
+  onAddToInventory,
+}: TagDetectedModalProps) {
+  const [syncing, setSyncing] = useState(false);
+  const [synced, setSynced] = useState(false);
+
+  // Reset sync state when spool changes
+  useEffect(() => {
+    setSyncing(false);
+    setSynced(false);
+  }, [spool?.id]);
+
+  // Handle escape key
+  const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    if (e.key === 'Escape') onClose();
+  }, [onClose]);
+
+  useEffect(() => {
+    if (isOpen) {
+      document.addEventListener('keydown', handleKeyDown);
+      document.body.style.overflow = 'hidden';
+    }
+    return () => {
+      document.removeEventListener('keydown', handleKeyDown);
+      document.body.style.overflow = '';
+    };
+  }, [isOpen, handleKeyDown]);
+
+  if (!isOpen) return null;
+
+  const handleSyncWeight = async () => {
+    if (scaleWeight === null || !weightStable || !spool) return;
+    setSyncing(true);
+    try {
+      await spoolbuddyApi.updateSpoolWeight(spool.id, Math.round(scaleWeight));
+      setSynced(true);
+      onSyncWeight();
+      setTimeout(() => setSynced(false), 3000);
+    } catch (e) {
+      console.error('Failed to sync weight:', e);
+    } finally {
+      setSyncing(false);
+    }
+  };
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-fade-in" onClick={onClose}>
+      <div
+        className="bg-zinc-800 rounded-2xl shadow-2xl w-full max-w-xl mx-4 animate-slide-up"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {spool ? (
+          <KnownSpoolView
+            spool={spool}
+            scaleWeight={scaleWeight}
+            weightStable={weightStable}
+            syncing={syncing}
+            synced={synced}
+            onSyncWeight={handleSyncWeight}
+            onAssignToAms={onAssignToAms}
+            onClose={onClose}
+          />
+        ) : (
+          <UnknownTagView
+            tagUid={tagUid}
+            scaleWeight={scaleWeight}
+            onAddToInventory={onAddToInventory}
+            onLinkSpool={onLinkSpool}
+            onClose={onClose}
+          />
+        )}
+      </div>
+    </div>
+  );
+}
+
+// --- Known spool view ---
+
+interface KnownSpoolViewProps {
+  spool: MatchedSpool;
+  scaleWeight: number | null;
+  weightStable: boolean;
+  syncing: boolean;
+  synced: boolean;
+  onSyncWeight: () => void;
+  onAssignToAms: () => void;
+  onClose: () => void;
+}
+
+function KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onSyncWeight, onAssignToAms, onClose }: KnownSpoolViewProps) {
+  const { t } = useTranslation();
+  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+
+  const coreWeight = (spool.core_weight && spool.core_weight > 0)
+    ? spool.core_weight
+    : getDefaultCoreWeight();
+
+  const grossWeight = scaleWeight !== null
+    ? Math.round(Math.max(0, scaleWeight))
+    : null;
+
+  const remaining = grossWeight !== null
+    ? Math.round(Math.max(0, grossWeight - coreWeight))
+    : null;
+
+  const labelWeight = Math.round(spool.label_weight || 1000);
+  const fillPercent = remaining !== null ? Math.min(100, Math.round((remaining / labelWeight) * 100)) : null;
+  const fillColor = fillPercent !== null
+    ? fillPercent > 50 ? '#22c55e' : fillPercent > 20 ? '#eab308' : '#ef4444'
+    : '#808080';
+
+  // Weight comparison
+  const netWeight = Math.max(0, (spool.label_weight || 0) - (spool.weight_used || 0));
+  const calculatedWeight = netWeight + coreWeight;
+  const difference = grossWeight !== null ? grossWeight - calculatedWeight : null;
+  const isMatch = difference !== null ? Math.abs(difference) <= 50 : null;
+
+  return (
+    <div className="p-6">
+      {/* Header */}
+      <div className="flex items-center justify-between mb-5">
+        <h2 className="text-lg font-semibold text-zinc-100">
+          {t('spoolbuddy.modal.spoolDetected', 'Spool Detected')}
+        </h2>
+        <button onClick={onClose} className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors">
+          <X className="w-5 h-5" />
+        </button>
+      </div>
+
+      {/* Spool info */}
+      <div className="flex items-start gap-5 mb-5">
+        <div className="relative shrink-0">
+          <SpoolIcon color={colorHex} isEmpty={false} size={100} />
+          {fillPercent !== null && (
+            <div
+              className="absolute -bottom-2 -right-2 px-2 py-0.5 rounded-full text-xs font-bold text-white shadow-lg"
+              style={{ backgroundColor: fillColor }}
+            >
+              {fillPercent}%
+            </div>
+          )}
+        </div>
+
+        <div className="flex-1 min-w-0 pt-1">
+          <h3 className="text-lg font-semibold text-zinc-100">
+            {spool.color_name || 'Unknown color'}
+          </h3>
+          <p className="text-sm text-zinc-400">
+            {spool.brand} &bull; {spool.material}
+            {spool.subtype && ` ${spool.subtype}`}
+          </p>
+
+          {remaining !== null && (
+            <div className="mt-3">
+              <div className="flex items-baseline gap-2">
+                <span className="text-3xl font-bold font-mono text-zinc-100">{remaining}g</span>
+                <span className="text-sm text-zinc-500">/ {labelWeight}g</span>
+              </div>
+              <p className="text-xs text-zinc-500 mt-0.5">{t('spoolbuddy.spool.remaining', 'Remaining')}</p>
+
+              <div className="mt-2 max-w-xs">
+                <div className="h-2 bg-zinc-700 rounded-full overflow-hidden">
+                  <div
+                    className="h-full rounded-full transition-all duration-500"
+                    style={{ width: `${fillPercent}%`, backgroundColor: fillColor }}
+                  />
+                </div>
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Details grid */}
+      <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm bg-zinc-900/50 rounded-lg p-4 mb-5">
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.grossWeight', 'Gross weight')}</span>
+          <span className="font-mono text-zinc-300">{grossWeight !== null ? `${grossWeight}g` : '\u2014'}</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.coreWeight', 'Core')}</span>
+          <span className="font-mono text-zinc-300">{coreWeight}g</span>
+        </div>
+        <div className="flex justify-between">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.spoolSize', 'Spool size')}</span>
+          <span className="font-mono text-zinc-300">{labelWeight}g</span>
+        </div>
+        <div className="flex justify-between items-center">
+          <span className="text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
+          {grossWeight !== null ? (
+            <span className={`flex items-center gap-1 font-mono ${isMatch ? 'text-green-500' : 'text-yellow-500'}`}>
+              {grossWeight}g
+              {isMatch ? <Check className="w-3.5 h-3.5" /> : <AlertTriangle className="w-3.5 h-3.5" />}
+            </span>
+          ) : (
+            <span className="text-zinc-500">{'\u2014'}</span>
+          )}
+        </div>
+        <div className="flex justify-between items-center">
+          <span className="text-zinc-500">{t('spoolbuddy.dashboard.tagId', 'Tag')}</span>
+          <span className="font-mono text-xs text-zinc-400 truncate max-w-[120px]" title={spool.tag_uid || ''}>
+            {spool.tag_uid ? spool.tag_uid.slice(-8) : '\u2014'}
+          </span>
+        </div>
+      </div>
+
+      {/* Action buttons */}
+      <div className="flex gap-3">
+        <button
+          onClick={onAssignToAms}
+          className="flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+        >
+          {t('spoolbuddy.modal.assignToAms', 'Assign to AMS')}
+        </button>
+        <button
+          onClick={onSyncWeight}
+          disabled={!weightStable || scaleWeight === null || syncing}
+          className={`flex-1 px-5 py-3 rounded-xl text-sm font-medium transition-colors min-h-[44px] ${
+            synced
+              ? 'bg-green-600/20 text-green-400'
+              : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 disabled:opacity-40 disabled:cursor-not-allowed'
+          }`}
+        >
+          {syncing ? (
+            <RefreshCw className="w-4 h-4 animate-spin inline-block mr-1.5" />
+          ) : synced ? (
+            <Check className="w-4 h-4 inline-block mr-1.5" />
+          ) : null}
+          {syncing
+            ? t('spoolbuddy.modal.syncing', 'Syncing...')
+            : synced
+              ? t('spoolbuddy.modal.weightSynced', 'Synced!')
+              : t('spoolbuddy.dashboard.syncWeight', 'Sync Weight')}
+        </button>
+        <button
+          onClick={onClose}
+          className="px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+        >
+          {t('spoolbuddy.dashboard.close', 'Close')}
+        </button>
+      </div>
+    </div>
+  );
+}
+
+// --- Unknown tag view ---
+
+interface UnknownTagViewProps {
+  tagUid: string | null;
+  scaleWeight: number | null;
+  onAddToInventory: () => void;
+  onLinkSpool?: () => void;
+  onClose: () => void;
+}
+
+function UnknownTagView({ tagUid, scaleWeight, onAddToInventory, onLinkSpool, onClose }: UnknownTagViewProps) {
+  const { t } = useTranslation();
+  const grossWeight = scaleWeight !== null
+    ? Math.round(Math.max(0, scaleWeight))
+    : null;
+
+  return (
+    <div className="p-6">
+      {/* Header */}
+      <div className="flex items-center justify-between mb-5">
+        <h2 className="text-lg font-semibold text-zinc-100">
+          {t('spoolbuddy.modal.newTagDetected', 'New Tag Detected')}
+        </h2>
+        <button onClick={onClose} className="p-2 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-700 transition-colors">
+          <X className="w-5 h-5" />
+        </button>
+      </div>
+
+      {/* Tag info */}
+      <div className="flex flex-col items-center text-center mb-6">
+        <div className="w-20 h-20 rounded-2xl bg-green-500/15 flex items-center justify-center mb-4">
+          <svg className="w-10 h-10 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
+          </svg>
+        </div>
+
+        <p className="text-sm text-zinc-500 font-mono mb-3">{tagUid}</p>
+
+        {grossWeight !== null && (
+          <div className="text-sm text-zinc-400">
+            <span className="font-mono font-semibold text-zinc-200 text-lg">{grossWeight}g</span>
+            <span className="ml-2">{t('spoolbuddy.dashboard.onScale', 'on scale')}</span>
+          </div>
+        )}
+      </div>
+
+      {/* Action buttons */}
+      <div className="flex gap-3">
+        <button
+          onClick={onAddToInventory}
+          className="flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+        >
+          {t('spoolbuddy.modal.addToInventory', 'Add to Inventory')}
+        </button>
+        {onLinkSpool && (
+          <button
+            onClick={onLinkSpool}
+            className="flex-1 px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.dashboard.linkSpool', 'Link to Spool')}
+          </button>
+        )}
+        <button
+          onClick={onClose}
+          className="px-5 py-3 rounded-xl text-sm font-medium bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
+        >
+          {t('spoolbuddy.dashboard.close', 'Close')}
+        </button>
+      </div>
+    </div>
+  );
+}

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

@@ -3567,6 +3567,25 @@ export default {
       grossWeight: 'Bruttogewicht',
       spoolSize: 'Spulengröße',
       close: 'Schließen',
+      currentSpool: 'Aktuelle Spule',
+    },
+    modal: {
+      spoolDetected: 'Spule erkannt',
+      assignToAms: 'AMS zuweisen',
+      syncWeight: 'Gewicht sync.',
+      weightSynced: 'Synchronisiert!',
+      syncing: 'Synchronisiere...',
+      newTagDetected: 'Neuer Tag erkannt',
+      addToInventory: 'Zum Inventar hinzufügen',
+      assignToAmsTitle: 'AMS zuweisen',
+      selectSlot: 'Slot auswählen',
+      assign: 'Zuweisen',
+      assigning: 'Zuweisen...',
+      assignSuccess: 'Zugewiesen!',
+      assignError: 'Fehler beim Zuweisen. Bitte erneut versuchen.',
+      noPrinterSelected: 'Drucker auswählen...',
+      noAmsDetected: 'Kein AMS an diesem Drucker erkannt',
+      slot: 'Slot',
     },
     weight: {
       noReading: 'Kein Messwert',

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

@@ -3572,6 +3572,25 @@ export default {
       grossWeight: 'Gross weight',
       spoolSize: 'Spool size',
       close: 'Close',
+      currentSpool: 'Current Spool',
+    },
+    modal: {
+      spoolDetected: 'Spool Detected',
+      assignToAms: 'Assign to AMS',
+      syncWeight: 'Sync Weight',
+      weightSynced: 'Synced!',
+      syncing: 'Syncing...',
+      newTagDetected: 'New Tag Detected',
+      addToInventory: 'Add to Inventory',
+      assignToAmsTitle: 'Assign to AMS',
+      selectSlot: 'Select a slot',
+      assign: 'Assign',
+      assigning: 'Assigning...',
+      assignSuccess: 'Assigned!',
+      assignError: 'Failed to assign spool. Please try again.',
+      noPrinterSelected: 'Select a printer...',
+      noAmsDetected: 'No AMS detected on this printer',
+      slot: 'Slot',
     },
     weight: {
       noReading: 'No reading',

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

@@ -3401,6 +3401,25 @@ export default {
       grossWeight: '総重量',
       spoolSize: 'スプールサイズ',
       close: '閉じる',
+      currentSpool: '現在のスプール',
+    },
+    modal: {
+      spoolDetected: 'スプール検出',
+      assignToAms: 'AMSに割り当て',
+      syncWeight: '重量同期',
+      weightSynced: '同期完了!',
+      syncing: '同期中...',
+      newTagDetected: '新しいタグを検出',
+      addToInventory: 'インベントリに追加',
+      assignToAmsTitle: 'AMSに割り当て',
+      selectSlot: 'スロットを選択',
+      assign: '割り当て',
+      assigning: '割り当て中...',
+      assignSuccess: '割り当て完了!',
+      assignError: 'スプールの割り当てに失敗しました。再試行してください。',
+      noPrinterSelected: 'プリンターを選択...',
+      noAmsDetected: 'このプリンターにAMSが検出されません',
+      slot: 'スロット',
     },
     weight: {
       noReading: '読み取りなし',

+ 56 - 37
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -1,11 +1,12 @@
 import { useState, useEffect, useMemo, useRef } from 'react';
-import { useOutletContext } from 'react-router-dom';
+import { useOutletContext, useNavigate } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
 import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
-import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
+import { TagDetectedModal } from '../../components/spoolbuddy/TagDetectedModal';
+import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
 import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
 
 // Color palette for the cycling spool animation
@@ -106,8 +107,9 @@ function DeviceOfflineState() {
 
 // --- Main Dashboard ---
 export function SpoolBuddyDashboard() {
-  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
+  const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
+  const navigate = useNavigate();
 
   // Fetch spools for stats, tag lookup, and untagged list
   const { data: spools = [], refetch: refetchSpools } = useQuery({
@@ -120,6 +122,8 @@ export function SpoolBuddyDashboard() {
   const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
   const [hiddenTagId, setHiddenTagId] = useState<string | null>(null);
   const [showLinkModal, setShowLinkModal] = useState(false);
+  const [showTagModal, setShowTagModal] = useState(false);
+  const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
 
   // Track current tag from state
   const currentTagId = sbState.matchedSpool?.tag_uid ?? sbState.unknownTagUid ?? null;
@@ -157,6 +161,7 @@ export function SpoolBuddyDashboard() {
         setDisplayedTagId(currentTagId);
         setDisplayedWeight(null);
         setHiddenTagId(null);
+        setShowTagModal(true);
       }
 
       // Update weight when stable and card is visible
@@ -197,6 +202,7 @@ export function SpoolBuddyDashboard() {
 
   const handleCloseSpoolCard = () => {
     setHiddenTagId(displayedTagId);
+    setShowTagModal(false);
   };
 
   const handleLinkTagToSpool = async (spool: InventorySpool) => {
@@ -210,11 +216,6 @@ export function SpoolBuddyDashboard() {
     }
   };
 
-  // Close handler for the Current Spool card
-  const showCard = displayedTagId && hiddenTagId !== displayedTagId;
-  const isMatchedSpool = displayedSpool !== null;
-  const isUnknownTag = showCard && !isMatchedSpool;
-
   // For unknown tags, use live weight or stored displayed weight
   const useScaleWeight = currentWeight !== null &&
     (currentTagId === displayedTagId || (currentTagId === null && displayedTagId !== null));
@@ -300,40 +301,58 @@ export function SpoolBuddyDashboard() {
               {t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}
             </h2>
             <div className="flex-1 flex items-center justify-center min-h-0">
-              {showCard && isMatchedSpool && displayedSpool ? (
-                <SpoolInfoCard
-                  spool={{
-                    id: displayedSpool.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,
-                  }}
-                  scaleWeight={liveWeight}
-                  weightStable={weightStable}
-                  onClose={handleCloseSpoolCard}
-                  onSyncWeight={() => refetchSpools()}
-                />
-              ) : showCard && isUnknownTag ? (
-                <UnknownTagCard
-                  tagUid={displayedTagId!}
-                  scaleWeight={liveWeight ?? (displayedWeight !== null ? displayedWeight : null)}
-                  onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
-                  onClose={handleCloseSpoolCard}
-                />
-              ) : (
-                sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />
-              )}
+              {sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />}
             </div>
           </div>
         </div>
       </div>
 
+      {/* Tag Detected Modal */}
+      <TagDetectedModal
+        isOpen={showTagModal && !!displayedTagId}
+        onClose={handleCloseSpoolCard}
+        spool={displayedSpool ? {
+          id: displayedSpool.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,
+        } : null}
+        tagUid={displayedTagId}
+        scaleWeight={liveWeight ?? displayedWeight}
+        weightStable={weightStable}
+        onSyncWeight={() => refetchSpools()}
+        onAssignToAms={() => setShowAssignAmsModal(true)}
+        onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
+        onAddToInventory={() => navigate(`/spoolbuddy/inventory?new=true&tag_uid=${displayedTagId}`)}
+      />
+
+      {/* Assign to AMS Modal */}
+      {displayedSpool && displayedTagId && (
+        <AssignToAmsModal
+          isOpen={showAssignAmsModal}
+          onClose={() => setShowAssignAmsModal(false)}
+          spool={{
+            id: displayedSpool.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,
+          }}
+          printerId={selectedPrinterId}
+        />
+      )}
+
       {/* Link Tag to Spool Modal */}
       {displayedTagId && (
         <LinkSpoolModal

+ 14 - 0
frontend/tailwind.config.js

@@ -25,6 +25,20 @@ export default {
       fontFamily: {
         sans: ['Inter', 'system-ui', 'sans-serif'],
       },
+      keyframes: {
+        fadeIn: {
+          '0%': { opacity: '0' },
+          '100%': { opacity: '1' },
+        },
+        slideUp: {
+          '0%': { opacity: '0', transform: 'translateY(16px) scale(0.98)' },
+          '100%': { opacity: '1', transform: 'translateY(0) scale(1)' },
+        },
+      },
+      animation: {
+        'fade-in': 'fadeIn 0.15s ease-out',
+        'slide-up': 'slideUp 0.2s ease-out',
+      },
     },
   },
   plugins: [],

+ 13 - 8
spoolbuddy/daemon/main.py

@@ -33,11 +33,12 @@ def _get_ip() -> str:
         return "unknown"
 
 
-async def nfc_poll_loop(config: Config, api: APIClient):
+async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
     """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
     from .nfc_reader import NFCReader
 
     nfc = NFCReader()
+    shared["nfc"] = nfc
     if not nfc.ok:
         logger.warning("NFC reader not available, skipping NFC polling")
         return
@@ -65,7 +66,7 @@ async def nfc_poll_loop(config: Config, api: APIClient):
         nfc.close()
 
 
-async def scale_poll_loop(config: Config, api: APIClient):
+async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
     """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
     from .scale_reader import ScaleReader
 
@@ -73,6 +74,7 @@ async def scale_poll_loop(config: Config, api: APIClient):
         tare_offset=config.tare_offset,
         calibration_factor=config.calibration_factor,
     )
+    shared["scale"] = scale
     if not scale.ok:
         logger.warning("Scale not available, skipping scale polling")
         return
@@ -107,7 +109,7 @@ async def scale_poll_loop(config: Config, api: APIClient):
         scale.close()
 
 
-async def heartbeat_loop(config: Config, api: APIClient, start_time: float):
+async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
     """Periodic heartbeat to keep device registered and pick up commands."""
 
     ip = _get_ip()
@@ -115,11 +117,13 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float):
     while True:
         await asyncio.sleep(config.heartbeat_interval)
 
+        nfc = shared.get("nfc")
+        scale = shared.get("scale")
         uptime = int(time.monotonic() - start_time)
         result = await api.heartbeat(
             device_id=config.device_id,
-            nfc_ok=True,
-            scale_ok=True,
+            nfc_ok=nfc.ok if nfc else False,
+            scale_ok=scale.ok if scale else False,
             uptime_s=uptime,
             ip_address=ip,
         )
@@ -164,11 +168,12 @@ async def main():
 
     logger.info("Device registered, starting poll loops")
 
+    shared: dict = {}
     try:
         await asyncio.gather(
-            nfc_poll_loop(config, api),
-            scale_poll_loop(config, api),
-            heartbeat_loop(config, api, start_time),
+            nfc_poll_loop(config, api, shared),
+            scale_poll_loop(config, api, shared),
+            heartbeat_loop(config, api, start_time, shared),
         )
     except KeyboardInterrupt:
         logger.info("Shutting down")

+ 84 - 8
spoolbuddy/daemon/nfc_reader.py

@@ -7,6 +7,8 @@ from enum import Enum, auto
 logger = logging.getLogger(__name__)
 
 MISS_THRESHOLD = 3  # Consecutive misses before declaring tag removed
+ERROR_RECOVERY_THRESHOLD = 10  # Consecutive errors before attempting RF reset
+RF_CYCLE_INTERVAL = 60.0  # Seconds between preventive RF cycles (when idle)
 
 
 class NFCState(Enum):
@@ -22,21 +24,57 @@ class NFCReader:
         self._current_sak: int | None = None
         self._miss_count = 0
         self._ok = False
+        self._error_count = 0
+        self._last_rf_cycle = 0.0
+        self._poll_count = 0
+        self._last_status_log = 0.0
 
         try:
             from read_tag import PN5180
 
             self._nfc = PN5180()
-            self._nfc.reset()
-            self._nfc.load_rf_config(0x00, 0x80)
+            self._init_rf()
+            self._ok = True
+            logger.info("NFC reader initialized")
+        except Exception as e:
+            logger.warning("NFC not available: %s", e)
+
+    def _init_rf(self):
+        """Full RF initialization sequence."""
+        self._nfc.reset()
+        self._nfc.load_rf_config(0x00, 0x80)
+        time.sleep(0.010)
+        self._nfc.rf_on()
+        time.sleep(0.030)
+        self._nfc.set_transceive_mode()
+        self._last_rf_cycle = time.monotonic()
+
+    def _rf_cycle(self):
+        """RF off/on cycle to recover from stuck state."""
+        try:
+            self._nfc.rf_off()
             time.sleep(0.010)
+            self._nfc.load_rf_config(0x00, 0x80)
+            time.sleep(0.005)
             self._nfc.rf_on()
-            time.sleep(0.030)
+            time.sleep(0.020)
             self._nfc.set_transceive_mode()
-            self._ok = True
-            logger.info("NFC reader initialized")
+            self._last_rf_cycle = time.monotonic()
+            return True
+        except Exception as e:
+            logger.warning("NFC RF cycle failed: %s", e)
+            return False
+
+    def _full_reset(self):
+        """Full hardware reset + RF init to recover from stuck state."""
+        try:
+            self._init_rf()
+            self._error_count = 0
+            logger.info("NFC reader recovered after full reset")
+            return True
         except Exception as e:
-            logger.info("NFC not available: %s", e)
+            logger.warning("NFC full reset failed: %s", e)
+            return False
 
     @property
     def ok(self) -> bool:
@@ -62,13 +100,51 @@ class NFCReader:
 
         event_type: "none", "tag_detected", "tag_removed"
         """
+        self._poll_count += 1
+
+        # Periodic status log (every 60s)
+        now = time.monotonic()
+        if now - self._last_status_log >= 60.0:
+            logger.info(
+                "NFC status: state=%s, polls=%d, errors=%d, ok=%s",
+                self._state.name,
+                self._poll_count,
+                self._error_count,
+                self._ok,
+            )
+            self._last_status_log = now
+
+        # Preventive RF cycle when idle (prevents reader drift)
+        if self._state == NFCState.IDLE and now - self._last_rf_cycle >= RF_CYCLE_INTERVAL:
+            self._rf_cycle()
+
         try:
             result = self._nfc.activate_type_a()
         except Exception as e:
-            logger.debug("NFC poll error: %s", e)
+            self._error_count += 1
             self._ok = False
+
+            if self._error_count == 1:
+                logger.warning("NFC poll error: %s", e)
+            elif self._error_count == ERROR_RECOVERY_THRESHOLD:
+                logger.warning(
+                    "NFC reader stuck (%d consecutive errors), attempting recovery...",
+                    self._error_count,
+                )
+                if self._full_reset():
+                    return "none", None
+                # Reset failed — will keep trying on next threshold
+                self._error_count = 0
+            elif self._error_count % ERROR_RECOVERY_THRESHOLD == 0:
+                logger.warning("NFC recovery attempt #%d", self._error_count // ERROR_RECOVERY_THRESHOLD)
+                self._full_reset()
+
             return "none", None
 
+        # Successful poll — clear error streak
+        if self._error_count > 0:
+            logger.info("NFC reader recovered after %d errors", self._error_count)
+        self._error_count = 0
         self._ok = True
 
         if result is not None:
@@ -90,7 +166,7 @@ class NFCReader:
                     if blocks:
                         tray_uuid = _extract_tray_uuid(blocks)
 
-                logger.info("Tag detected: %s (SAK=0x%02X)", uid_hex, sak)
+                logger.info("Tag detected: %s (SAK=0x%02X, type=%s)", uid_hex, sak, tag_type)
                 return "tag_detected", {
                     "tag_uid": uid_hex,
                     "sak": sak,

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


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


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


+ 2 - 2
static/index.html

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

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