Browse Source

Spoolbuddy frontend fixes

maziggy 2 months ago
parent
commit
f2d28a0ef8

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

@@ -17,6 +17,7 @@ from backend.app.schemas.spoolbuddy import (
     CalibrationResponse,
     DeviceRegisterRequest,
     DeviceResponse,
+    DisplaySettingsRequest,
     HeartbeatRequest,
     HeartbeatResponse,
     ScaleReadingRequest,
@@ -54,6 +55,12 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         has_scale=device.has_scale,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
+        nfc_reader_type=device.nfc_reader_type,
+        nfc_connection=device.nfc_connection,
+        display_brightness=device.display_brightness,
+        display_blank_timeout=device.display_blank_timeout,
+        has_backlight=device.has_backlight,
+        last_calibrated_at=device.last_calibrated_at,
         last_seen=device.last_seen,
         pending_command=device.pending_command,
         nfc_ok=device.nfc_ok,
@@ -85,6 +92,9 @@ async def register_device(
         device.firmware_version = req.firmware_version
         device.has_nfc = req.has_nfc
         device.has_scale = req.has_scale
+        device.nfc_reader_type = req.nfc_reader_type
+        device.nfc_connection = req.nfc_connection
+        device.has_backlight = req.has_backlight
         device.last_seen = now
         logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
     else:
@@ -97,6 +107,9 @@ async def register_device(
             has_scale=req.has_scale,
             tare_offset=req.tare_offset,
             calibration_factor=req.calibration_factor,
+            nfc_reader_type=req.nfc_reader_type,
+            nfc_connection=req.nfc_connection,
+            has_backlight=req.has_backlight,
             last_seen=now,
         )
         db.add(device)
@@ -151,6 +164,10 @@ async def device_heartbeat(
         device.firmware_version = req.firmware_version
     if req.ip_address:
         device.ip_address = req.ip_address
+    if req.nfc_reader_type:
+        device.nfc_reader_type = req.nfc_reader_type
+    if req.nfc_connection:
+        device.nfc_connection = req.nfc_connection
 
     # Return and clear pending command
     pending = device.pending_command
@@ -171,6 +188,8 @@ async def device_heartbeat(
         pending_command=pending,
         tare_offset=device.tare_offset,
         calibration_factor=device.calibration_factor,
+        display_brightness=device.display_brightness,
+        display_blank_timeout=device.display_blank_timeout,
     )
 
 
@@ -322,6 +341,7 @@ async def set_tare_offset(
         raise HTTPException(status_code=404, detail="Device not registered")
 
     device.tare_offset = req.tare_offset
+    device.last_calibrated_at = datetime.now(timezone.utc)
     await db.commit()
 
     logger.info("SpoolBuddy %s tare offset set to %d", device_id, req.tare_offset)
@@ -352,6 +372,7 @@ async def set_calibration_factor(
     device.calibration_factor = req.known_weight_grams / raw_delta
     if req.tare_raw_adc is not None:
         device.tare_offset = tare
+    device.last_calibrated_at = datetime.now(timezone.utc)
     await db.commit()
 
     logger.info(
@@ -386,6 +407,106 @@ async def get_calibration(
     )
 
 
+# --- Display settings ---
+
+
+@router.put("/devices/{device_id}/display")
+async def update_display_settings(
+    device_id: str,
+    req: DisplaySettingsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update display brightness and screen blank timeout for a device."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    device.display_brightness = req.brightness
+    device.display_blank_timeout = req.blank_timeout
+    await db.commit()
+
+    logger.info(
+        "SpoolBuddy %s display updated: brightness=%d%%, blank_timeout=%ds",
+        device_id,
+        req.brightness,
+        req.blank_timeout,
+    )
+    return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
+
+
+# --- Update check ---
+
+
+@router.get("/devices/{device_id}/update-check")
+async def check_daemon_update(
+    device_id: str,
+    include_beta: bool = False,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """Check if a newer daemon version is available on GitHub."""
+    import httpx
+
+    from backend.app.api.routes.updates import is_newer_version, parse_version
+    from backend.app.core.config import GITHUB_REPO
+
+    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")
+
+    current = device.firmware_version or "0.0.0"
+
+    try:
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
+                headers={"Accept": "application/vnd.github.v3+json"},
+                timeout=10.0,
+            )
+            response.raise_for_status()
+            releases = response.json()
+
+            release_data = None
+            for release in releases:
+                tag = release.get("tag_name", "")
+                if include_beta:
+                    release_data = release
+                    break
+                else:
+                    parsed = parse_version(tag)
+                    if parsed[4] == 0:  # is_prerelease == 0
+                        release_data = release
+                        break
+
+            if not release_data:
+                return {
+                    "current_version": current,
+                    "latest_version": None,
+                    "update_available": False,
+                    "release_url": None,
+                }
+
+            latest = release_data.get("tag_name", "").lstrip("v")
+            return {
+                "current_version": current,
+                "latest_version": latest,
+                "update_available": is_newer_version(latest, current),
+                "release_url": release_data.get("html_url"),
+            }
+    except Exception as e:
+        logger.warning("Failed to check for daemon updates: %s", e)
+        return {
+            "current_version": current,
+            "latest_version": None,
+            "update_available": False,
+            "release_url": None,
+            "error": str(e),
+        }
+
+
 # --- Background watchdog ---
 
 

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

@@ -1328,6 +1328,32 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add NFC reader and display control columns to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_reader_type VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_connection VARCHAR(20)"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN display_brightness INTEGER DEFAULT 100"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN display_blank_timeout INTEGER DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN has_backlight BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN last_calibrated_at DATETIME"))
+    except OperationalError:
+        pass  # Already applied
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

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

@@ -20,6 +20,12 @@ class SpoolBuddyDevice(Base):
     has_scale: Mapped[bool] = mapped_column(Boolean, default=True)
     tare_offset: Mapped[int] = mapped_column(Integer, default=0)
     calibration_factor: Mapped[float] = mapped_column(Float, default=1.0)
+    nfc_reader_type: Mapped[str | None] = mapped_column(String(20))
+    nfc_connection: Mapped[str | None] = mapped_column(String(20))
+    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)
+    last_calibrated_at: Mapped[datetime | None] = mapped_column(DateTime)
     last_seen: Mapped[datetime | None] = mapped_column(DateTime)
     pending_command: Mapped[str | None] = mapped_column(String(50))
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)

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

@@ -14,6 +14,9 @@ class DeviceRegisterRequest(BaseModel):
     has_scale: bool = True
     tare_offset: int = 0
     calibration_factor: float = 1.0
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
+    has_backlight: bool = False
 
 
 class DeviceResponse(BaseModel):
@@ -26,6 +29,12 @@ class DeviceResponse(BaseModel):
     has_scale: bool
     tare_offset: int
     calibration_factor: float
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
+    display_brightness: int = 100
+    display_blank_timeout: int = 0
+    has_backlight: bool = False
+    last_calibrated_at: datetime | None = None
     last_seen: datetime | None = None
     pending_command: str | None = None
     nfc_ok: bool
@@ -45,12 +54,16 @@ class HeartbeatRequest(BaseModel):
     uptime_s: int = 0
     firmware_version: str | None = None
     ip_address: str | None = None
+    nfc_reader_type: str | None = None
+    nfc_connection: str | None = None
 
 
 class HeartbeatResponse(BaseModel):
     pending_command: str | None = None
     tare_offset: int
     calibration_factor: float
+    display_brightness: int = 100
+    display_blank_timeout: int = 0
 
 
 # --- NFC schemas ---
@@ -105,3 +118,11 @@ class SetCalibrationFactorRequest(BaseModel):
 class CalibrationResponse(BaseModel):
     tare_offset: int
     calibration_factor: float
+
+
+# --- Display schemas ---
+
+
+class DisplaySettingsRequest(BaseModel):
+    brightness: int = Field(ge=0, le=100)
+    blank_timeout: int = Field(ge=0)

+ 2 - 0
frontend/src/App.tsx

@@ -28,6 +28,7 @@ import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
 import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
 import { SpoolBuddyInventoryPage } from './pages/spoolbuddy/SpoolBuddyInventoryPage';
 import { SpoolBuddySettingsPage } from './pages/spoolbuddy/SpoolBuddySettingsPage';
+import { SpoolBuddyCalibrationPage } from './pages/spoolbuddy/SpoolBuddyCalibrationPage';
 import { VirtualKeyboard } from './components/VirtualKeyboard';
 
 const queryClient = new QueryClient({
@@ -126,6 +127,7 @@ function App() {
                   <Route path="spoolbuddy/ams" element={<SpoolBuddyAmsPage />} />
                   <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
                   <Route path="spoolbuddy/settings" element={<SpoolBuddySettingsPage />} />
+                  <Route path="spoolbuddy/calibration" element={<SpoolBuddyCalibrationPage />} />
                 </Route>
 
                 {/* Main app with WebSocket for real-time updates */}

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

@@ -4864,6 +4864,12 @@ export interface SpoolBuddyDevice {
   has_scale: boolean;
   tare_offset: number;
   calibration_factor: number;
+  nfc_reader_type: string | null;
+  nfc_connection: string | null;
+  display_brightness: number;
+  display_blank_timeout: number;
+  has_backlight: boolean;
+  last_calibrated_at: string | null;
   last_seen: string | null;
   pending_command: string | null;
   nfc_ok: boolean;
@@ -4872,6 +4878,13 @@ export interface SpoolBuddyDevice {
   online: boolean;
 }
 
+export interface DaemonUpdateCheck {
+  current_version: string;
+  latest_version: string | null;
+  update_available: boolean;
+  release_url: string | null;
+}
+
 // SpoolBuddy API
 export const spoolbuddyApi = {
   getDevices: () =>
@@ -4897,4 +4910,13 @@ export const spoolbuddyApi = {
       method: 'POST',
       body: JSON.stringify({ spool_id: spoolId, weight_grams: weightGrams }),
     }),
+
+  updateDisplay: (deviceId: string, brightness: number, blankTimeout: number) =>
+    request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/display`, {
+      method: 'PUT',
+      body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
+    }),
+
+  checkDaemonUpdate: (deviceId: string, includeBeta?: boolean) =>
+    request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check?include_beta=${includeBeta ?? false}`),
 };

+ 222 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyCalibrationPage.tsx

@@ -0,0 +1,222 @@
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { useNavigate, useOutletContext } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
+import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
+
+function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
+  device: SpoolBuddyDevice;
+  weight: number | null;
+  weightStable: boolean;
+  rawAdc: number | null;
+}) {
+  const { t } = useTranslation();
+  const [calibrating, setCalibrating] = useState(false);
+  const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');
+  const [knownWeight, setKnownWeight] = useState('500');
+  const [tareRawAdc, setTareRawAdc] = useState<number | null>(null);
+  const [taring, setTaring] = useState(false);
+
+  const numpadPress = (key: string) => {
+    if (key === 'backspace') {
+      setKnownWeight((v) => v.slice(0, -1) || '');
+    } else if (key === '.' && !knownWeight.includes('.')) {
+      setKnownWeight((v) => v + '.');
+    } else if (key >= '0' && key <= '9') {
+      setKnownWeight((v) => (v === '0' ? key : v + key));
+    }
+  };
+
+  const handleTare = async () => {
+    setTaring(true);
+    try {
+      await spoolbuddyApi.tare(device.device_id);
+    } catch (e) {
+      console.error('Failed to tare:', e);
+    } finally {
+      setTaring(false);
+    }
+  };
+
+  const startCalibration = () => {
+    setCalStep('tare');
+  };
+
+  const handleCalStep = async () => {
+    if (calStep === 'tare') {
+      setCalibrating(true);
+      try {
+        // Capture raw ADC before taring — this is our zero reference
+        setTareRawAdc(rawAdc);
+        await spoolbuddyApi.tare(device.device_id);
+        setCalStep('weight');
+      } catch (e) {
+        console.error('Failed to tare:', e);
+      } finally {
+        setCalibrating(false);
+      }
+    } else if (calStep === 'weight') {
+      const weightNum = parseFloat(knownWeight);
+      if (rawAdc === null || !weightNum || weightNum <= 0) return;
+      setCalibrating(true);
+      try {
+        await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined);
+        setCalStep('idle');
+      } catch (e) {
+        console.error('Failed to calibrate:', e);
+      } finally {
+        setCalibrating(false);
+      }
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Current weight */}
+      <div className="bg-zinc-800 rounded-lg p-4">
+        <div className="flex items-center justify-between">
+          <span className="text-sm text-zinc-400">{t('spoolbuddy.settings.currentWeight', 'Current weight')}</span>
+          <div className="flex items-center gap-2">
+            <div className={`w-2 h-2 rounded-full ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />
+            <span className="text-lg font-mono text-zinc-200">
+              {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
+            </span>
+          </div>
+        </div>
+
+        {/* Tare offset + calibration factor */}
+        <div className="grid grid-cols-2 gap-4 mt-3 text-xs">
+          <div className="flex justify-between">
+            <span className="text-zinc-500">{t('spoolbuddy.settings.tareOffset', 'Tare offset')}</span>
+            <span className="text-zinc-400 font-mono">{device.tare_offset}</span>
+          </div>
+          <div className="flex justify-between">
+            <span className="text-zinc-500">{t('spoolbuddy.settings.calFactor', 'Cal. factor')}</span>
+            <span className="text-zinc-400 font-mono">{device.calibration_factor.toFixed(2)}</span>
+          </div>
+        </div>
+      </div>
+
+      {/* Calibration flow */}
+      {calStep === 'idle' ? (
+        <div className="flex gap-2">
+          <button
+            onClick={handleTare}
+            disabled={taring}
+            className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px]"
+          >
+            {taring ? '...' : t('spoolbuddy.weight.tare', 'Tare')}
+          </button>
+          <button
+            onClick={startCalibration}
+            className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
+          >
+            {t('spoolbuddy.weight.calibrate', 'Calibrate')}
+          </button>
+        </div>
+      ) : (
+        <div className="bg-zinc-800 border border-zinc-700 rounded-lg p-3 space-y-2">
+          <div className="text-sm font-medium text-zinc-200">
+            {calStep === 'tare'
+              ? t('spoolbuddy.settings.calStep1', 'Step 1: Remove all items from the scale')
+              : t('spoolbuddy.settings.calStep2', 'Step 2: Place known weight on scale')}
+          </div>
+
+          {calStep === 'weight' && (
+            <div className="space-y-1.5">
+              <div className="flex items-center gap-2">
+                <span className="text-xs text-zinc-400">{t('spoolbuddy.settings.knownWeight', 'Known weight (g)')}</span>
+                <div className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-right text-base font-mono text-zinc-100">
+                  {knownWeight || '0'}<span className="text-zinc-500 ml-1">g</span>
+                </div>
+              </div>
+              <div className="grid grid-cols-4 gap-1">
+                {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
+                  <button
+                    key={key}
+                    onClick={() => numpadPress(key)}
+                    className={`py-2 rounded text-sm font-medium transition-colors min-h-[36px] ${
+                      key === 'backspace'
+                        ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
+                        : 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'
+                    }`}
+                  >
+                    {key === 'backspace' ? '\u232B' : key}
+                  </button>
+                ))}
+              </div>
+            </div>
+          )}
+
+          <div className="flex gap-2">
+            <button
+              onClick={() => setCalStep('idle')}
+              className="flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[40px]"
+            >
+              {t('common.cancel', 'Cancel')}
+            </button>
+            <button
+              onClick={handleCalStep}
+              disabled={calibrating}
+              className="flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[40px]"
+            >
+              {calibrating ? '...' : calStep === 'tare' ? t('spoolbuddy.settings.setZero', 'Set Zero') : t('spoolbuddy.settings.calibrateNow', 'Calibrate')}
+            </button>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+export function SpoolBuddyCalibrationPage() {
+  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+
+  const { data: devices = [] } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: () => spoolbuddyApi.getDevices(),
+    refetchInterval: 10000,
+  });
+
+  const device = sbState.deviceId
+    ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]
+    : devices[0];
+
+  return (
+    <div className="h-full flex flex-col p-4">
+      <div className="flex items-center gap-3 mb-4">
+        <button
+          onClick={() => navigate('/spoolbuddy/settings')}
+          className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
+        >
+          <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
+          </svg>
+        </button>
+        <h1 className="text-xl font-semibold text-zinc-100">
+          {t('spoolbuddy.settings.scaleCalibration', 'Scale Calibration')}
+        </h1>
+      </div>
+
+      <div className="flex-1 min-h-0 overflow-y-auto">
+        {!device ? (
+          <div className="flex items-center justify-center h-32">
+            <div className="text-center text-zinc-500">
+              <p className="text-sm">{t('spoolbuddy.settings.noDevice', 'No SpoolBuddy device found')}</p>
+            </div>
+          </div>
+        ) : (
+          <ScaleCalibration
+            device={device}
+            weight={sbState.weight}
+            weightStable={sbState.weightStable}
+            rawAdc={sbState.rawAdc}
+          />
+        )}
+      </div>
+    </div>
+  );
+}

+ 510 - 143
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -1,9 +1,9 @@
-import { useState } from 'react';
+import { useState, useCallback, useRef, useEffect } from 'react';
 import { useQuery } from '@tanstack/react-query';
 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 { spoolbuddyApi, type SpoolBuddyDevice, type DaemonUpdateCheck } from '../../api/client';
 
 function formatUptime(seconds: number): string {
   if (seconds < 60) return `${seconds}s`;
@@ -13,18 +13,254 @@ function formatUptime(seconds: number): string {
   return `${h}h ${m}m`;
 }
 
-function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
+function formatDateTime(iso: string | null): string {
+  if (!iso) return '-';
+  try {
+    const d = new Date(iso);
+    return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
+      d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+  } catch {
+    return '-';
+  }
+}
+
+const BLANK_OPTIONS = [
+  { label: 'Off', value: 0 },
+  { label: '1m', value: 60 },
+  { label: '2m', value: 120 },
+  { label: '5m', value: 300 },
+  { label: '10m', value: 600 },
+  { label: '30m', value: 1800 },
+];
+
+// --- Device Tab ---
+
+function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="space-y-4">
+      {/* About */}
+      <div className="bg-zinc-800 rounded-lg p-4">
+        <div className="flex items-center gap-3 mb-2">
+          <img src="/img/spoolbuddy_logo_dark_small.png" alt="SpoolBuddy" className="h-7 w-auto" />
+        </div>
+        <p className="text-xs text-zinc-500 mb-1">Part of Bambuddy</p>
+        <a
+          href="https://github.com/maziggy/bambuddy"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-xs text-blue-400 hover:text-blue-300"
+        >
+          github.com/maziggy/bambuddy
+        </a>
+      </div>
+
+      {/* NFC Reader + Device Info side by side */}
+      <div className="grid grid-cols-2 gap-3">
+        {/* NFC Reader */}
+        <div className="bg-zinc-800 rounded-lg p-3">
+          <h3 className="text-sm font-semibold text-zinc-300 mb-2">
+            {t('spoolbuddy.settings.nfcReader', 'NFC Reader')}
+          </h3>
+          <div className="space-y-1.5 text-xs">
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.type', 'Type')}</span>
+              <span className="text-zinc-300 font-mono">
+                {device.nfc_reader_type || 'N/A'}
+              </span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.connection', 'Connection')}</span>
+              <span className="text-zinc-300 font-mono">
+                {device.nfc_connection || 'N/A'}
+              </span>
+            </div>
+            <div className="flex justify-between items-center">
+              <span className="text-zinc-500">{t('spoolbuddy.status.status', 'Status')}</span>
+              <div className="flex items-center gap-1.5">
+                <div className={`w-2 h-2 rounded-full ${
+                  device.nfc_ok ? 'bg-green-500' : device.nfc_reader_type ? 'bg-red-500' : 'bg-zinc-600'
+                }`} />
+                <span className={
+                  device.nfc_ok ? 'text-green-400' : device.nfc_reader_type ? 'text-red-400' : 'text-zinc-500'
+                }>
+                  {device.nfc_ok
+                    ? t('spoolbuddy.status.nfcReady', 'Ready')
+                    : device.nfc_reader_type
+                      ? t('common.error', 'Error')
+                      : t('spoolbuddy.settings.notConnected', 'N/A')}
+                </span>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        {/* Device Info */}
+        <div className="bg-zinc-800 rounded-lg p-3">
+          <h3 className="text-sm font-semibold text-zinc-300 mb-2">
+            {t('spoolbuddy.settings.deviceInfo', 'Device Info')}
+          </h3>
+          <div className="space-y-1.5 text-xs">
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.hostname', 'Host')}</span>
+              <span className="text-zinc-300 truncate ml-2">{device.hostname}</span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-zinc-500">IP</span>
+              <span className="text-zinc-300">{device.ip_address}</span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.uptime', 'Uptime')}</span>
+              <span className="text-zinc-300">{formatUptime(device.uptime_s)}</span>
+            </div>
+            <div className="flex justify-between items-center">
+              <span className="text-zinc-500">{t('spoolbuddy.status.status', 'Status')}</span>
+              <div className="flex items-center gap-1.5">
+                <div className={`w-2 h-2 rounded-full ${device.online ? 'bg-green-500' : 'bg-zinc-600'}`} />
+                <span className={device.online ? 'text-green-400' : 'text-zinc-500'}>
+                  {device.online ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
+                </span>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* Device ID (full width, below cards) */}
+      <div className="bg-zinc-800 rounded-lg px-3 py-2 flex justify-between items-center text-xs">
+        <span className="text-zinc-500">Device ID</span>
+        <span className="text-zinc-400 font-mono">{device.device_id}</span>
+      </div>
+    </div>
+  );
+}
+
+// --- Display Tab ---
+
+function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
+  const { t } = useTranslation();
+  const [brightness, setBrightness] = useState(device.display_brightness);
+  const [blankTimeout, setBlankTimeout] = useState(device.display_blank_timeout);
+  const [saved, setSaved] = useState(false);
+  const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+  const savedTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
+
+  // Sync local state when device data updates from server
+  useEffect(() => {
+    setBrightness(device.display_brightness);
+    setBlankTimeout(device.display_blank_timeout);
+  }, [device.display_brightness, device.display_blank_timeout]);
+
+  const showSaved = useCallback(() => {
+    setSaved(true);
+    if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
+    savedTimerRef.current = setTimeout(() => setSaved(false), 1500);
+  }, []);
+
+  const sendDisplayUpdate = useCallback((b: number, bt: number) => {
+    if (debounceRef.current) clearTimeout(debounceRef.current);
+    debounceRef.current = setTimeout(() => {
+      spoolbuddyApi.updateDisplay(device.device_id, b, bt)
+        .then(() => showSaved())
+        .catch((e) => console.error('Failed to update display:', e));
+    }, 300);
+  }, [device.device_id, showSaved]);
+
+  const handleBrightnessChange = (value: number) => {
+    setBrightness(value);
+    sendDisplayUpdate(value, blankTimeout);
+  };
+
+  const handleBlankTimeoutChange = (value: number) => {
+    setBlankTimeout(value);
+    sendDisplayUpdate(brightness, value);
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Brightness */}
+      <div className="bg-zinc-800 rounded-lg p-4">
+        <div className="flex items-center justify-between mb-3">
+          <h3 className="text-sm font-semibold text-zinc-300">
+            {t('spoolbuddy.settings.brightness', 'Brightness')}
+          </h3>
+          {saved && (
+            <span className="text-xs text-green-400 flex items-center gap-1 animate-pulse">
+              <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+              </svg>
+              Saved
+            </span>
+          )}
+        </div>
+        <div className="flex items-center gap-3">
+          <svg className="w-4 h-4 text-zinc-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
+          </svg>
+          <input
+            type="range"
+            min={0}
+            max={100}
+            value={brightness}
+            onChange={(e) => handleBrightnessChange(parseInt(e.target.value))}
+            className="flex-1 h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-green-500"
+          />
+          <span className="text-sm font-mono text-zinc-400 w-10 text-right">{brightness}%</span>
+        </div>
+        {!device.has_backlight && (
+          <p className="text-xs text-zinc-600 mt-2">
+            {t('spoolbuddy.settings.noBacklight', 'No DSI backlight detected. Brightness control requires a DSI display.')}
+          </p>
+        )}
+      </div>
+
+      {/* Screen blank timeout */}
+      <div className="bg-zinc-800 rounded-lg p-4">
+        <h3 className="text-sm font-semibold text-zinc-300 mb-1">
+          {t('spoolbuddy.settings.screenBlank', 'Screen Blank Timeout')}
+        </h3>
+        <p className="text-xs text-zinc-500 mb-3">
+          {t('spoolbuddy.settings.screenBlankDesc', 'Screen turns off after inactivity. Wakes on NFC scan or weight change.')}
+        </p>
+        <div className="grid grid-cols-3 gap-2">
+          {BLANK_OPTIONS.map((opt) => (
+            <button
+              key={opt.value}
+              onClick={() => handleBlankTimeoutChange(opt.value)}
+              className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors min-h-[40px] ${
+                blankTimeout === opt.value
+                  ? 'bg-green-600 text-white'
+                  : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
+              }`}
+            >
+              {opt.label}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      <p className="text-xs text-zinc-600 text-center">
+        {t('spoolbuddy.settings.displayNote', 'Display settings are applied by the daemon on the next heartbeat cycle.')}
+      </p>
+    </div>
+  );
+}
+
+// --- Scale Tab ---
+
+function ScaleTab({ device, weight, weightStable, rawAdc }: {
   device: SpoolBuddyDevice;
   weight: number | null;
   weightStable: boolean;
   rawAdc: number | null;
 }) {
   const { t } = useTranslation();
-  const [calibrating, setCalibrating] = useState(false);
   const [calStep, setCalStep] = useState<'idle' | 'tare' | 'weight'>('idle');
   const [knownWeight, setKnownWeight] = useState('500');
   const [tareRawAdc, setTareRawAdc] = useState<number | null>(null);
-  const [taring, setTaring] = useState(false);
+  const [busy, setBusy] = useState(false);
+  const [status, setStatus] = useState<{ type: 'ok' | 'error'; msg: string } | null>(null);
 
   const numpadPress = (key: string) => {
     if (key === 'backspace') {
@@ -37,142 +273,161 @@ function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
   };
 
   const handleTare = async () => {
-    setTaring(true);
+    setBusy(true);
+    setStatus(null);
     try {
       await spoolbuddyApi.tare(device.device_id);
-    } catch (e) {
-      console.error('Failed to tare:', e);
+      setStatus({ type: 'ok', msg: t('spoolbuddy.settings.tareSet', 'Tare command sent. Waiting for device...') });
+    } catch {
+      setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') });
     } finally {
-      setTaring(false);
+      setBusy(false);
     }
   };
 
-  const startCalibration = () => {
-    setCalStep('tare');
-  };
-
   const handleCalStep = async () => {
     if (calStep === 'tare') {
-      setCalibrating(true);
+      setBusy(true);
+      setStatus(null);
       try {
-        // Capture raw ADC before taring — this is our zero reference
         setTareRawAdc(rawAdc);
         await spoolbuddyApi.tare(device.device_id);
+        setStatus({ type: 'ok', msg: t('spoolbuddy.settings.zeroSet', 'Zero point set. Place known weight on scale.') });
         setCalStep('weight');
-      } catch (e) {
-        console.error('Failed to tare:', e);
+      } catch {
+        setStatus({ type: 'error', msg: t('spoolbuddy.settings.tareFailed', 'Failed to send tare command') });
       } finally {
-        setCalibrating(false);
+        setBusy(false);
       }
     } else if (calStep === 'weight') {
       const weightNum = parseFloat(knownWeight);
       if (rawAdc === null || !weightNum || weightNum <= 0) return;
-      setCalibrating(true);
+      setBusy(true);
+      setStatus(null);
       try {
         await spoolbuddyApi.setCalibrationFactor(device.device_id, weightNum, rawAdc, tareRawAdc ?? undefined);
+        setStatus({ type: 'ok', msg: t('spoolbuddy.settings.calibrationDone', 'Calibration complete!') });
         setCalStep('idle');
-      } catch (e) {
-        console.error('Failed to calibrate:', e);
+      } catch {
+        setStatus({ type: 'error', msg: t('spoolbuddy.settings.calibrationFailed', 'Calibration failed') });
       } finally {
-        setCalibrating(false);
+        setBusy(false);
       }
     }
   };
 
   return (
-    <div className="bg-zinc-800 rounded-lg p-4">
-      <h3 className="text-base font-semibold text-zinc-100 mb-4">
-        {t('spoolbuddy.settings.scaleCalibration', 'Scale Calibration')}
-      </h3>
-
-      {/* Current weight */}
-      <div className="flex items-center justify-between mb-3">
-        <span className="text-sm text-zinc-400">{t('spoolbuddy.settings.currentWeight', 'Current weight')}</span>
-        <div className="flex items-center gap-2">
-          <div className={`w-2 h-2 rounded-full ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />
-          <span className="text-sm font-mono text-zinc-200">
-            {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
-          </span>
+    <div className="flex flex-col h-full">
+      {/* Weight + info row */}
+      <div className="bg-zinc-800 rounded-lg p-3 mb-3">
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-2">
+            <div className={`w-2 h-2 rounded-full ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />
+            <span className="text-lg font-mono text-zinc-200">
+              {weight !== null ? `${weight.toFixed(1)} g` : '-- g'}
+            </span>
+          </div>
+          <div className="text-xs text-zinc-500 text-right">
+            <span>{t('spoolbuddy.settings.tareOffset', 'Tare')}: {device.tare_offset}</span>
+            <span className="mx-1.5">&middot;</span>
+            <span>{t('spoolbuddy.settings.calFactor', 'Factor')}: {device.calibration_factor.toFixed(2)}</span>
+          </div>
         </div>
+        {device.last_calibrated_at && (
+          <div className="text-xs text-zinc-600 mt-1">
+            {t('spoolbuddy.settings.lastCalibrated', 'Last calibrated')}: {formatDateTime(device.last_calibrated_at)}
+          </div>
+        )}
       </div>
 
-      {/* Tare offset + calibration factor */}
-      <div className="grid grid-cols-2 gap-4 mb-4 text-xs">
-        <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.settings.tareOffset', 'Tare offset')}</span>
-          <span className="text-zinc-400 font-mono">{device.tare_offset}</span>
-        </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.settings.calFactor', 'Cal. factor')}</span>
-          <span className="text-zinc-400 font-mono">{device.calibration_factor.toFixed(2)}</span>
+      {/* Status message */}
+      {status && (
+        <div className={`rounded-lg px-3 py-2 mb-3 text-sm ${
+          status.type === 'ok' ? 'bg-green-900/30 text-green-300 border border-green-800' : 'bg-red-900/30 text-red-300 border border-red-800'
+        }`}>
+          {status.msg}
         </div>
-      </div>
+      )}
 
       {/* Calibration flow */}
       {calStep === 'idle' ? (
         <div className="flex gap-2">
           <button
             onClick={handleTare}
-            disabled={taring}
-            className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px]"
+            disabled={busy}
+            className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2"
           >
-            {taring ? '...' : t('spoolbuddy.weight.tare', 'Tare')}
+            {busy && (
+              <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
+                <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+              </svg>
+            )}
+            {t('spoolbuddy.weight.tare', 'Tare')}
           </button>
           <button
-            onClick={startCalibration}
+            onClick={() => { setCalStep('tare'); setStatus(null); }}
             className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 transition-colors min-h-[44px]"
           >
             {t('spoolbuddy.weight.calibrate', 'Calibrate')}
           </button>
         </div>
       ) : (
-        <div className="border border-zinc-700 rounded-lg p-3 space-y-2">
-          <div className="text-sm font-medium text-zinc-200">
-            {calStep === 'tare'
-              ? t('spoolbuddy.settings.calStep1', 'Step 1: Remove all items from the scale')
-              : t('spoolbuddy.settings.calStep2', 'Step 2: Place known weight on scale')}
-          </div>
+        <div className="flex-1 flex flex-col min-h-0">
+          <div className="bg-zinc-800 border border-zinc-700 rounded-lg p-3 flex flex-col flex-1 min-h-0">
+            <div className="text-sm font-medium text-zinc-200 mb-2">
+              {calStep === 'tare'
+                ? t('spoolbuddy.settings.calStep1', 'Step 1: Remove all items from the scale')
+                : t('spoolbuddy.settings.calStep2', 'Step 2: Place known weight on scale')}
+            </div>
 
-          {calStep === 'weight' && (
-            <div className="space-y-1.5">
-              <div className="flex items-center gap-2">
-                <span className="text-xs text-zinc-400">{t('spoolbuddy.settings.knownWeight', 'Known weight (g)')}</span>
-                <div className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-right text-base font-mono text-zinc-100">
-                  {knownWeight || '0'}<span className="text-zinc-500 ml-1">g</span>
+            {calStep === 'weight' && (
+              <div className="flex-1 flex flex-col min-h-0">
+                <div className="flex items-center gap-2 mb-1.5">
+                  <span className="text-xs text-zinc-400">{t('spoolbuddy.settings.knownWeight', 'Weight (g)')}</span>
+                  <div className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1 text-right text-base font-mono text-zinc-100">
+                    {knownWeight || '0'}<span className="text-zinc-500 ml-1">g</span>
+                  </div>
+                </div>
+                <div className="grid grid-cols-4 gap-1 flex-1">
+                  {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
+                    <button
+                      key={key}
+                      onClick={() => numpadPress(key)}
+                      className={`rounded text-sm font-medium transition-colors ${
+                        key === 'backspace'
+                          ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
+                          : 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'
+                      }`}
+                    >
+                      {key === 'backspace' ? '\u232B' : key}
+                    </button>
+                  ))}
                 </div>
               </div>
-              <div className="grid grid-cols-4 gap-1">
-                {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
-                  <button
-                    key={key}
-                    onClick={() => numpadPress(key)}
-                    className={`py-2 rounded text-sm font-medium transition-colors min-h-[36px] ${
-                      key === 'backspace'
-                        ? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
-                        : 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700 border border-zinc-700'
-                    }`}
-                  >
-                    {key === 'backspace' ? '⌫' : key}
-                  </button>
-                ))}
-              </div>
-            </div>
-          )}
+            )}
 
-          <div className="flex gap-2">
-            <button
-              onClick={() => setCalStep('idle')}
-              className="flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[40px]"
-            >
-              {t('common.cancel', 'Cancel')}
-            </button>
-            <button
-              onClick={handleCalStep}
-              disabled={calibrating}
-              className="flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[40px]"
-            >
-              {calibrating ? '...' : calStep === 'tare' ? t('spoolbuddy.settings.setZero', 'Set Zero') : t('spoolbuddy.settings.calibrateNow', 'Calibrate')}
-            </button>
+            <div className="flex gap-2 mt-2">
+              <button
+                onClick={() => { setCalStep('idle'); setStatus(null); }}
+                className="flex-1 px-4 py-2 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[40px]"
+              >
+                {t('common.cancel', 'Cancel')}
+              </button>
+              <button
+                onClick={handleCalStep}
+                disabled={busy}
+                className="flex-1 px-4 py-2 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[40px] flex items-center justify-center gap-2"
+              >
+                {busy && (
+                  <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
+                    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+                    <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+                  </svg>
+                )}
+                {calStep === 'tare' ? t('spoolbuddy.settings.setZero', 'Set Zero') : t('spoolbuddy.settings.calibrateNow', 'Calibrate')}
+              </button>
+            </div>
           </div>
         </div>
       )}
@@ -180,62 +435,145 @@ function ScaleCalibration({ device, weight, weightStable, rawAdc }: {
   );
 }
 
-function DeviceInfoCard({ device }: { device: SpoolBuddyDevice }) {
+// --- Updates Tab ---
+
+function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
   const { t } = useTranslation();
+  const [checking, setChecking] = useState(false);
+  const [updateResult, setUpdateResult] = useState<DaemonUpdateCheck | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [includeBeta, setIncludeBeta] = useState(() => {
+    try {
+      return localStorage.getItem('spoolbuddy-include-beta') === 'true';
+    } catch {
+      return false;
+    }
+  });
+
+  const toggleBeta = () => {
+    const next = !includeBeta;
+    setIncludeBeta(next);
+    try {
+      localStorage.setItem('spoolbuddy-include-beta', String(next));
+    } catch {
+      // localStorage unavailable
+    }
+    setUpdateResult(null);
+    setError(null);
+  };
+
+  const checkForUpdates = async () => {
+    setChecking(true);
+    setUpdateResult(null);
+    setError(null);
+    try {
+      const result = await spoolbuddyApi.checkDaemonUpdate(device.device_id, includeBeta);
+      setUpdateResult(result);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : 'Failed to check for updates');
+    } finally {
+      setChecking(false);
+    }
+  };
+
+  // Show version from device, or from update check result if available
+  const displayVersion = device.firmware_version
+    || (updateResult?.current_version && updateResult.current_version !== '0.0.0' ? updateResult.current_version : null);
 
   return (
-    <div className="bg-zinc-800 rounded-lg p-4">
-      <h3 className="text-base font-semibold text-zinc-100 mb-4">
-        {t('spoolbuddy.settings.deviceInfo', 'Device Info')}
-      </h3>
-
-      <div className="space-y-2 text-sm">
-        <div className="flex justify-between">
-          <span className="text-zinc-500">Device ID</span>
-          <span className="text-zinc-300 font-mono text-xs">{device.device_id}</span>
-        </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.settings.hostname', 'Hostname')}</span>
-          <span className="text-zinc-300">{device.hostname}</span>
-        </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">IP</span>
-          <span className="text-zinc-300">{device.ip_address}</span>
-        </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.settings.firmware', 'Firmware')}</span>
-          <span className="text-zinc-300">{device.firmware_version ?? '-'}</span>
-        </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">NFC</span>
-          <span className={device.nfc_ok ? 'text-green-400' : 'text-zinc-500'}>
-            {device.nfc_ok ? t('spoolbuddy.status.nfcReady', 'Ready') : t('spoolbuddy.status.nfcOff', 'Off')}
+    <div className="space-y-4">
+      {/* Current version */}
+      <div className="bg-zinc-800 rounded-lg p-4">
+        <h3 className="text-sm font-semibold text-zinc-300 mb-3">
+          {t('spoolbuddy.settings.daemonVersion', 'Daemon Version')}
+        </h3>
+        <div className="flex justify-between items-center text-sm">
+          <span className="text-zinc-500">{t('spoolbuddy.settings.currentVersion', 'Current')}</span>
+          <span className="text-zinc-200 font-mono">
+            {displayVersion || (
+              <span className="text-zinc-500 italic">{t('spoolbuddy.settings.versionPending', 'Waiting for daemon...')}</span>
+            )}
           </span>
         </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.settings.scale', 'Scale')}</span>
-          <span className={device.scale_ok ? 'text-green-400' : 'text-red-400'}>
-            {device.scale_ok ? 'OK' : t('common.error', 'Error')}
-          </span>
-        </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.settings.uptime', 'Uptime')}</span>
-          <span className="text-zinc-300">{formatUptime(device.uptime_s)}</span>
-        </div>
-        <div className="flex justify-between">
-          <span className="text-zinc-500">{t('spoolbuddy.status.status', 'Status')}</span>
-          <span className={device.online ? 'text-green-400' : 'text-zinc-500'}>
-            {device.online ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
-          </span>
+      </div>
+
+      {/* Check for updates */}
+      <div className="bg-zinc-800 rounded-lg p-4 space-y-3">
+        <button
+          onClick={checkForUpdates}
+          disabled={checking}
+          className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-zinc-700 text-zinc-200 hover:bg-zinc-600 disabled:opacity-40 transition-colors min-h-[44px] flex items-center justify-center gap-2"
+        >
+          {checking && (
+            <svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
+              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+            </svg>
+          )}
+          {checking ? t('spoolbuddy.settings.checking', 'Checking...') : t('spoolbuddy.settings.checkUpdates', 'Check for Updates')}
+        </button>
+
+        {/* Error feedback */}
+        {error && (
+          <div className="rounded-lg p-3 text-sm bg-red-900/30 border border-red-800">
+            <p className="text-red-300">{error}</p>
+          </div>
+        )}
+
+        {/* Result feedback */}
+        {updateResult && (
+          <div className={`rounded-lg p-3 text-sm ${
+            updateResult.update_available
+              ? 'bg-green-900/30 border border-green-800'
+              : 'bg-zinc-700/50'
+          }`}>
+            {updateResult.update_available ? (
+              <div className="space-y-1">
+                <p className="text-green-300 font-medium">
+                  {t('spoolbuddy.settings.updateAvailable', 'Update available')}: v{updateResult.latest_version}
+                </p>
+                <p className="text-xs text-zinc-400">
+                  {t('spoolbuddy.settings.updateInstructions', 'Update via SSH: run the SpoolBuddy install script to upgrade.')}
+                </p>
+              </div>
+            ) : (
+              <div className="flex items-center gap-2">
+                <svg className="w-4 h-4 text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                </svg>
+                <p className="text-zinc-300">{t('spoolbuddy.settings.upToDate', 'Up to date')}</p>
+              </div>
+            )}
+          </div>
+        )}
+
+        {/* Include beta toggle */}
+        <div className="flex items-center justify-between pt-1">
+          <span className="text-xs text-zinc-500">{t('spoolbuddy.settings.includeBeta', 'Include beta versions')}</span>
+          <button
+            onClick={toggleBeta}
+            className={`relative w-10 h-5 rounded-full transition-colors ${
+              includeBeta ? 'bg-green-600' : 'bg-zinc-600'
+            }`}
+          >
+            <div className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
+              includeBeta ? 'translate-x-5' : 'translate-x-0.5'
+            }`} />
+          </button>
         </div>
       </div>
     </div>
   );
 }
 
+// --- Main Settings Page ---
+
+type SettingsTab = 'device' | 'display' | 'scale' | 'updates';
+
 export function SpoolBuddySettingsPage() {
   const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
+  const [activeTab, setActiveTab] = useState<SettingsTab>('device');
 
   const { data: devices = [] } = useQuery({
     queryKey: ['spoolbuddy-devices'],
@@ -248,13 +586,38 @@ export function SpoolBuddySettingsPage() {
     ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]
     : devices[0];
 
+  const tabs: { id: SettingsTab; label: string }[] = [
+    { id: 'device', label: t('spoolbuddy.settings.tabDevice', 'Device') },
+    { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
+    { id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') },
+    { id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') },
+  ];
+
   return (
     <div className="h-full flex flex-col p-4">
-      <h1 className="text-xl font-semibold text-zinc-100 mb-4">
+      <h1 className="text-xl font-semibold text-zinc-100 mb-3">
         {t('spoolbuddy.nav.settings', 'Settings')}
       </h1>
 
-      <div className="flex-1 min-h-0 overflow-y-auto space-y-4">
+      {/* Tab bar */}
+      <div className="flex gap-1 bg-zinc-800/50 rounded-lg p-1 mb-4">
+        {tabs.map((tab) => (
+          <button
+            key={tab.id}
+            onClick={() => setActiveTab(tab.id)}
+            className={`flex-1 px-2 py-2 rounded-md text-sm font-medium transition-colors min-h-[36px] ${
+              activeTab === tab.id
+                ? 'bg-zinc-700 text-zinc-100'
+                : 'text-zinc-500 hover:text-zinc-300'
+            }`}
+          >
+            {tab.label}
+          </button>
+        ))}
+      </div>
+
+      {/* Content */}
+      <div className="flex-1 min-h-0 overflow-y-auto">
         {!device ? (
           <div className="flex items-center justify-center h-32">
             <div className="text-center text-zinc-500">
@@ -263,13 +626,17 @@ export function SpoolBuddySettingsPage() {
           </div>
         ) : (
           <>
-            <ScaleCalibration
-              device={device}
-              weight={sbState.weight}
-              weightStable={sbState.weightStable}
-              rawAdc={sbState.rawAdc}
-            />
-            <DeviceInfoCard device={device} />
+            {activeTab === 'device' && <DeviceTab device={device} />}
+            {activeTab === 'display' && <DisplayTab device={device} />}
+            {activeTab === 'scale' && (
+              <ScaleTab
+                device={device}
+                weight={sbState.weight}
+                weightStable={sbState.weightStable}
+                rawAdc={sbState.rawAdc}
+              />
+            )}
+            {activeTab === 'updates' && <UpdatesTab device={device} />}
           </>
         )}
       </div>

+ 1 - 0
spoolbuddy/daemon/__init__.py

@@ -0,0 +1 @@
+__version__ = "0.2.2b1"

+ 18 - 1
spoolbuddy/daemon/api_client.py

@@ -67,6 +67,9 @@ class APIClient:
         has_scale: bool = True,
         tare_offset: int = 0,
         calibration_factor: float = 1.0,
+        nfc_reader_type: str | None = None,
+        nfc_connection: str | None = None,
+        has_backlight: bool = False,
     ) -> dict | None:
         while True:
             result = await self._post(
@@ -80,6 +83,9 @@ class APIClient:
                     "has_scale": has_scale,
                     "tare_offset": tare_offset,
                     "calibration_factor": calibration_factor,
+                    "nfc_reader_type": nfc_reader_type,
+                    "nfc_connection": nfc_connection,
+                    "has_backlight": has_backlight,
                 },
             )
             if result is not None:
@@ -90,7 +96,15 @@ class APIClient:
             self._backoff = min(self._backoff * 2, self._max_backoff)
 
     async def heartbeat(
-        self, device_id: str, nfc_ok: bool, scale_ok: bool, uptime_s: int, ip_address: str | None = None
+        self,
+        device_id: str,
+        nfc_ok: bool,
+        scale_ok: bool,
+        uptime_s: int,
+        ip_address: str | None = None,
+        firmware_version: str | None = None,
+        nfc_reader_type: str | None = None,
+        nfc_connection: str | None = None,
     ) -> dict | None:
         result = await self._post(
             f"/devices/{device_id}/heartbeat",
@@ -99,6 +113,9 @@ class APIClient:
                 "scale_ok": scale_ok,
                 "uptime_s": uptime_s,
                 "ip_address": ip_address,
+                "firmware_version": firmware_version,
+                "nfc_reader_type": nfc_reader_type,
+                "nfc_connection": nfc_connection,
             },
         )
         if result and self._buffer:

+ 93 - 0
spoolbuddy/daemon/display_control.py

@@ -0,0 +1,93 @@
+"""Display brightness and screen blanking control for SpoolBuddy kiosk."""
+
+import logging
+import subprocess
+import time
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+BACKLIGHT_BASE = Path("/sys/class/backlight")
+
+
+class DisplayControl:
+    def __init__(self):
+        self._backlight_path = self._find_backlight()
+        self._max_brightness = self._read_max_brightness()
+        self._blank_timeout = 0  # seconds, 0 = disabled
+        self._last_activity = time.monotonic()
+        self._blanked = False
+
+        if self._backlight_path:
+            logger.info("Backlight found: %s (max=%d)", self._backlight_path, self._max_brightness)
+        else:
+            logger.info("No DSI backlight found, brightness control unavailable")
+
+    def _find_backlight(self) -> Path | None:
+        if not BACKLIGHT_BASE.exists():
+            return None
+        for entry in BACKLIGHT_BASE.iterdir():
+            brightness_file = entry / "brightness"
+            if brightness_file.exists():
+                return entry
+        return None
+
+    def _read_max_brightness(self) -> int:
+        if not self._backlight_path:
+            return 100
+        try:
+            return int((self._backlight_path / "max_brightness").read_text().strip())
+        except Exception:
+            return 255
+
+    @property
+    def has_backlight(self) -> bool:
+        return self._backlight_path is not None
+
+    def set_brightness(self, pct: int):
+        """Set backlight brightness (0-100%). No-op if no backlight."""
+        if not self._backlight_path:
+            return
+        pct = max(0, min(100, pct))
+        value = round(self._max_brightness * pct / 100)
+        try:
+            (self._backlight_path / "brightness").write_text(str(value))
+            logger.debug("Brightness set to %d%% (%d/%d)", pct, value, self._max_brightness)
+        except Exception as e:
+            logger.warning("Failed to set brightness: %s", e)
+
+    def set_blank_timeout(self, seconds: int):
+        """Set screen blank timeout in seconds. 0 = disabled."""
+        self._blank_timeout = max(0, seconds)
+
+    def wake(self):
+        """Wake screen on activity (NFC tag, scale weight change)."""
+        self._last_activity = time.monotonic()
+        if self._blanked:
+            self._unblank()
+
+    def tick(self):
+        """Called periodically from heartbeat loop. Blanks screen if idle."""
+        if self._blank_timeout <= 0:
+            if self._blanked:
+                self._unblank()
+            return
+        idle = time.monotonic() - self._last_activity
+        if not self._blanked and idle >= self._blank_timeout:
+            self._blank()
+
+    def _blank(self):
+        try:
+            subprocess.run(["wlopm", "--off", "*"], capture_output=True, timeout=5)
+            self._blanked = True
+            logger.debug("Screen blanked after idle timeout")
+        except Exception as e:
+            logger.warning("Failed to blank screen: %s", e)
+
+    def _unblank(self):
+        try:
+            subprocess.run(["wlopm", "--on", "*"], capture_output=True, timeout=5)
+            self._blanked = False
+            logger.debug("Screen unblanked")
+        except Exception as e:
+            logger.warning("Failed to unblank screen: %s", e)

+ 41 - 14
spoolbuddy/daemon/main.py

@@ -11,8 +11,12 @@ from pathlib import Path
 # Add scripts/ to sys.path so hardware drivers (read_tag, scale_diag) are importable
 sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
 
+from . import __version__
 from .api_client import APIClient
 from .config import Config
+from .display_control import DisplayControl
+from .nfc_reader import NFCReader
+from .scale_reader import ScaleReader
 
 logging.basicConfig(
     level=logging.INFO,
@@ -35,10 +39,8 @@ def _get_ip() -> str:
 
 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
+    nfc: NFCReader = shared["nfc"]
+    display: DisplayControl = shared["display"]
     if not nfc.ok:
         logger.warning("NFC reader not available, skipping NFC polling")
         return
@@ -48,6 +50,7 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
             event_type, event_data = await asyncio.to_thread(nfc.poll)
 
             if event_type == "tag_detected":
+                display.wake()
                 await api.tag_scanned(
                     device_id=config.device_id,
                     tag_uid=event_data["tag_uid"],
@@ -68,13 +71,8 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
 
 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
-
-    scale = ScaleReader(
-        tare_offset=config.tare_offset,
-        calibration_factor=config.calibration_factor,
-    )
-    shared["scale"] = scale
+    scale: ScaleReader = shared["scale"]
+    display: DisplayControl = shared["display"]
     if not scale.ok:
         logger.warning("Scale not available, skipping scale polling")
         return
@@ -95,6 +93,7 @@ async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
                     weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
 
                     if weight_changed:
+                        display.wake()
                         await api.scale_reading(
                             device_id=config.device_id,
                             weight_grams=grams,
@@ -111,7 +110,7 @@ async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
 
 async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
     """Periodic heartbeat to keep device registered and pick up commands."""
-
+    display: DisplayControl = shared["display"]
     ip = _get_ip()
 
     while True:
@@ -126,6 +125,9 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
             scale_ok=scale.ok if scale else False,
             uptime_s=uptime,
             ip_address=ip,
+            firmware_version=__version__,
+            nfc_reader_type=nfc.reader_type if nfc else None,
+            nfc_connection=nfc.connection if nfc else None,
         )
 
         if result:
@@ -152,34 +154,59 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
                     scale.update_calibration(tare, cal)
                 logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
 
+            # Apply display settings from backend
+            brightness = result.get("display_brightness")
+            blank_timeout = result.get("display_blank_timeout")
+            if brightness is not None:
+                display.set_brightness(brightness)
+            if blank_timeout is not None:
+                display.set_blank_timeout(blank_timeout)
+
+        display.tick()
+
 
 async def main():
     config = Config.load()
-    logger.info("SpoolBuddy daemon starting (device=%s, backend=%s)", config.device_id, config.backend_url)
+    logger.info(
+        "SpoolBuddy daemon v%s starting (device=%s, backend=%s)", __version__, config.device_id, config.backend_url
+    )
 
     api = APIClient(config.backend_url, config.api_key)
     ip = _get_ip()
     start_time = time.monotonic()
 
+    # Initialize hardware before registration so we can report capabilities
+    nfc = NFCReader()
+    scale = ScaleReader(
+        tare_offset=config.tare_offset,
+        calibration_factor=config.calibration_factor,
+    )
+    display = DisplayControl()
+
     # Register with backend (retries until success)
     reg = await api.register_device(
         device_id=config.device_id,
         hostname=config.hostname,
         ip_address=ip,
+        firmware_version=__version__,
         has_nfc=True,
         has_scale=True,
         tare_offset=config.tare_offset,
         calibration_factor=config.calibration_factor,
+        nfc_reader_type=nfc.reader_type,
+        nfc_connection=nfc.connection,
+        has_backlight=display.has_backlight,
     )
 
     # Use server-side calibration if available
     if reg:
         config.tare_offset = reg.get("tare_offset", config.tare_offset)
         config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
+        scale.update_calibration(config.tare_offset, config.calibration_factor)
 
     logger.info("Device registered, starting poll loops")
 
-    shared: dict = {}
+    shared: dict = {"nfc": nfc, "scale": scale, "display": display}
     try:
         await asyncio.gather(
             nfc_poll_loop(config, api, shared),

+ 10 - 0
spoolbuddy/daemon/nfc_reader.py

@@ -57,6 +57,16 @@ class NFCReader:
             logger.warning("NFC full reset failed: %s", e)
             return False
 
+    @property
+    def reader_type(self) -> str:
+        """Return NFC reader hardware type."""
+        return "PN5180" if self._nfc is not None else "Unknown"
+
+    @property
+    def connection(self) -> str:
+        """Return NFC reader connection type."""
+        return "SPI" if self._nfc is not None else "None"
+
     @property
     def ok(self) -> bool:
         return self._ok

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


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


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


+ 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-BO0c-VbW.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BW2QyQko.css">
+    <script type="module" crossorigin src="/assets/index-CExaOFN6.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DR3vNyF5.css">
   </head>
   <body>
     <div id="root"></div>

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