Kaynağa Gözat

Spoolbuddy

  1. Device tab — repo URL unlinked

  Replaced the <a> tag with a <span> so the GitHub URL is displayed as plain text (no clickable link in kiosk mode).

  2. Display controls — moved to frontend

  Root cause: The daemon runs as a system user without Wayland access, and the kiosk uses an HDMI display (no sysfs backlight). Neither wlopm, vcgencmd, nor sysfs writes
  can work in this setup.

  Fix — frontend-controlled display in SpoolBuddyLayout.tsx:
  - Brightness: CSS filter: brightness(X) applied to the layout root. Immediate visual dimming that works on any display type.
  - Screen blanking: Full-screen black overlay (z-[9999]) shown after the configured inactivity timeout. Touch anywhere to wake.
  - Activity tracking: Resets on pointerdown/keydown events AND on WebSocket-driven NFC/scale changes (weight update, tag scan).
  - Queries device data every 15s to read the current brightness and blank timeout values.

  Daemon display_control.py — Simplified:
  - Removed vcgencmd/wlopm/subprocess usage entirely
  - _blank/_unblank are now state-tracking no-ops (log only)
  - DSI backlight brightness via sysfs is preserved as a bonus for DSI displays
  - Updated docstring to explain the architecture

  3. Scale tab — redesigned with step indicator

  Idle state: Same as before — weight card with tare/calibrate buttons.

  Calibration wizard: Two-column layout:
  - Left column (~64px): Vertical step indicator with numbered circles, checkmark for completed steps, and a connecting line
  - Right column: Live weight bar (with stable/settling indicator), step-specific content (instructions or numpad), and action buttons pinned to bottom via mt-auto

  The numpad buttons are min-h-[52px] with text-lg — large enough for touch on 1024x600. The weight info card is hidden during the weight entry step to maximize vertical
  space.
maziggy 2 ay önce
ebeveyn
işleme
48b492f619

+ 92 - 16
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -1,16 +1,30 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
 import { Outlet } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
 import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+import { spoolbuddyApi } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 
 export function SpoolBuddyLayout() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
+  const [blanked, setBlanked] = useState(false);
+  const lastActivityRef = useRef(Date.now());
   const sbState = useSpoolBuddyState();
 
+  // Query device data for display settings (brightness + blank timeout)
+  const { data: devices = [] } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: () => spoolbuddyApi.getDevices(),
+    refetchInterval: 15000,
+  });
+  const device = devices[0];
+  const brightness = device?.display_brightness ?? 100;
+  const blankTimeout = device?.display_blank_timeout ?? 0;
+
   // Force dark theme on mount, restore on unmount
   useEffect(() => {
     const root = document.documentElement;
@@ -30,22 +44,84 @@ export function SpoolBuddyLayout() {
     }
   }, [sbState.deviceOnline]);
 
+  // Track user activity for screen blank
+  const resetActivity = useCallback(() => {
+    lastActivityRef.current = Date.now();
+    setBlanked(false);
+  }, []);
+
+  useEffect(() => {
+    window.addEventListener('pointerdown', resetActivity);
+    window.addEventListener('keydown', resetActivity);
+    return () => {
+      window.removeEventListener('pointerdown', resetActivity);
+      window.removeEventListener('keydown', resetActivity);
+    };
+  }, [resetActivity]);
+
+  // Reset on NFC/scale activity (WebSocket events)
+  const prevWeightRef = useRef(sbState.weight);
+  const prevSpoolRef = useRef(sbState.matchedSpool);
+  const prevTagRef = useRef(sbState.unknownTagUid);
+  useEffect(() => {
+    if (
+      sbState.weight !== prevWeightRef.current ||
+      sbState.matchedSpool !== prevSpoolRef.current ||
+      sbState.unknownTagUid !== prevTagRef.current
+    ) {
+      prevWeightRef.current = sbState.weight;
+      prevSpoolRef.current = sbState.matchedSpool;
+      prevTagRef.current = sbState.unknownTagUid;
+      lastActivityRef.current = Date.now();
+      setBlanked(false);
+    }
+  }, [sbState.weight, sbState.matchedSpool, sbState.unknownTagUid]);
+
+  // Screen blank timer
+  useEffect(() => {
+    if (blankTimeout <= 0) return;
+    const interval = setInterval(() => {
+      if (Date.now() - lastActivityRef.current >= blankTimeout * 1000) {
+        setBlanked(true);
+      }
+    }, 1000);
+    return () => clearInterval(interval);
+  }, [blankTimeout]);
+
+  // CSS brightness filter (software dimming for HDMI displays)
+  const brightnessStyle = brightness < 100
+    ? { filter: `brightness(${brightness / 100})` } as const
+    : undefined;
+
   return (
-    <div className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden">
-      <SpoolBuddyTopBar
-        selectedPrinterId={selectedPrinterId}
-        onPrinterChange={setSelectedPrinterId}
-        deviceOnline={sbState.deviceOnline}
-      />
-
-      <main className="flex-1 overflow-y-auto">
-        <Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState, setAlert }} />
-      </main>
-
-      <SpoolBuddyStatusBar alert={alert} />
-      <SpoolBuddyBottomNav />
-      <VirtualKeyboard />
-    </div>
+    <>
+      <div
+        className="w-screen h-screen bg-bambu-dark text-white flex flex-col overflow-hidden"
+        style={brightnessStyle}
+      >
+        <SpoolBuddyTopBar
+          selectedPrinterId={selectedPrinterId}
+          onPrinterChange={setSelectedPrinterId}
+          deviceOnline={sbState.deviceOnline}
+        />
+
+        <main className="flex-1 overflow-y-auto">
+          <Outlet context={{ selectedPrinterId, setSelectedPrinterId, sbState, setAlert }} />
+        </main>
+
+        <SpoolBuddyStatusBar alert={alert} />
+        <SpoolBuddyBottomNav />
+        <VirtualKeyboard />
+      </div>
+
+      {/* Screen blank overlay — touch to wake */}
+      {blanked && (
+        <div
+          className="fixed inset-0 bg-black z-[9999]"
+          onPointerDown={(e) => { e.stopPropagation(); resetActivity(); }}
+        />
+      )}
+    </>
   );
 }
 

+ 137 - 88
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -46,14 +46,7 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
           <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>
+        <span className="text-xs text-zinc-500">github.com/maziggy/bambuddy</span>
       </div>
 
       {/* NFC Reader + Device Info side by side */}
@@ -241,7 +234,7 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
       </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.')}
+        {t('spoolbuddy.settings.displayNote', 'Brightness is applied as a software filter. Screen blank activates after inactivity — touch to wake.')}
       </p>
     </div>
   );
@@ -249,6 +242,43 @@ function DisplayTab({ device }: { device: SpoolBuddyDevice }) {
 
 // --- Scale Tab ---
 
+function StepIndicator({ step }: { step: 'tare' | 'weight' }) {
+  return (
+    <div className="flex flex-col items-center w-16 shrink-0 pt-1">
+      {/* Step 1 circle */}
+      <div className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
+        step === 'tare'
+          ? 'bg-green-600 text-white'
+          : 'bg-green-600/20 text-green-400'
+      }`}>
+        {step === 'weight' ? (
+          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+          </svg>
+        ) : '1'}
+      </div>
+      <span className={`text-[10px] mt-0.5 ${step === 'tare' ? 'text-green-400 font-medium' : 'text-green-400/60'}`}>
+        Tare
+      </span>
+
+      {/* Connector line */}
+      <div className={`w-px h-5 my-1 ${step === 'weight' ? 'bg-green-600/40' : 'bg-zinc-700'}`} />
+
+      {/* Step 2 circle */}
+      <div className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
+        step === 'weight'
+          ? 'bg-green-600 text-white'
+          : 'bg-zinc-700 text-zinc-500'
+      }`}>
+        2
+      </div>
+      <span className={`text-[10px] mt-0.5 ${step === 'weight' ? 'text-green-400 font-medium' : 'text-zinc-600'}`}>
+        Weight
+      </span>
+    </div>
+  );
+}
+
 function ScaleTab({ device, weight, weightStable, rawAdc }: {
   device: SpoolBuddyDevice;
   weight: number | null;
@@ -316,10 +346,11 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
     }
   };
 
-  return (
-    <div className="flex flex-col h-full">
-      {/* Weight + info row — hidden during weight entry to maximize numpad space */}
-      {calStep !== 'weight' && (
+  // --- Idle state: weight card + buttons ---
+  if (calStep === 'idle') {
+    return (
+      <div className="flex flex-col h-full">
+        {/* Weight + info card */}
         <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">
@@ -340,19 +371,17 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
             </div>
           )}
         </div>
-      )}
-
-      {/* 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>
-      )}
 
-      {/* Calibration flow */}
-      {calStep === 'idle' ? (
+        {/* 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>
+        )}
+
+        {/* Action buttons */}
         <div className="flex gap-2">
           <button
             onClick={handleTare}
@@ -374,74 +403,94 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
             {t('spoolbuddy.weight.calibrate', 'Calibrate')}
           </button>
         </div>
-      ) : (
-        <div className="flex-1 flex flex-col min-h-0">
-          {/* Step header with inline live weight */}
-          <div className="flex items-center justify-between mb-2">
-            <div className="text-sm font-medium text-zinc-200">
-              {calStep === 'tare'
-                ? t('spoolbuddy.settings.calStep1', 'Step 1: Remove all items from scale')
-                : t('spoolbuddy.settings.calStep2', 'Step 2: Place known weight on scale')}
-            </div>
-            <div className="flex items-center gap-1.5 ml-2 shrink-0">
-              <div className={`w-1.5 h-1.5 rounded-full ${weightStable ? 'bg-green-500' : 'bg-amber-500 animate-pulse'}`} />
-              <span className="text-xs font-mono text-zinc-400">
-                {weight !== null ? `${weight.toFixed(1)}g` : '--'}
-              </span>
-            </div>
+      </div>
+    );
+  }
+
+  // --- Calibration wizard: step indicator left + content right ---
+  return (
+    <div className="flex h-full gap-3">
+      {/* Left: step indicator */}
+      <StepIndicator step={calStep} />
+
+      {/* Right: content */}
+      <div className="flex-1 flex flex-col min-h-0 min-w-0">
+        {/* Live weight bar */}
+        <div className="flex items-center gap-2 bg-zinc-800 rounded-lg px-3 py-2 mb-2">
+          <div className={`w-2 h-2 rounded-full shrink-0 ${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>
+          <span className={`text-xs ml-auto ${weightStable ? 'text-green-400' : 'text-amber-400'}`}>
+            {weightStable ? 'Stable' : 'Settling...'}
+          </span>
+        </div>
+
+        {/* Status message */}
+        {status && (
+          <div className={`rounded-lg px-3 py-1.5 mb-2 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>
+        )}
 
-          {calStep === 'weight' && (
-            <>
-              {/* Weight input + numpad */}
-              <div className="flex items-center gap-2 mb-2">
-                <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.5 text-right text-lg 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.5 mb-2">
-                {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
-                  <button
-                    key={key}
-                    onClick={() => numpadPress(key)}
-                    className={`rounded text-lg font-medium transition-colors min-h-[56px] active:scale-95 ${
-                      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>
-                ))}
+        {/* Step content */}
+        {calStep === 'tare' ? (
+          <div className="flex-1 flex flex-col">
+            <p className="text-sm text-zinc-300 mb-4">
+              {t('spoolbuddy.settings.calStep1', 'Remove all items from the scale and press Set Zero.')}
+            </p>
+          </div>
+        ) : (
+          <div className="flex-1 flex flex-col min-h-0">
+            <div className="flex items-center gap-2 mb-2">
+              <span className="text-xs text-zinc-400 shrink-0">{t('spoolbuddy.settings.knownWeight', 'Known weight')}</span>
+              <div className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-3 py-1.5 text-right text-lg font-mono text-zinc-100">
+                {knownWeight || '0'}<span className="text-zinc-500 ml-1">g</span>
               </div>
-            </>
-          )}
-
-          {/* Action buttons */}
-          <div className="flex gap-2 mt-auto">
-            <button
-              onClick={() => { setCalStep('idle'); setStatus(null); }}
-              className="flex-1 px-4 py-2.5 rounded-lg text-sm bg-zinc-700 text-zinc-300 hover:bg-zinc-600 transition-colors min-h-[44px]"
-            >
-              {t('common.cancel', 'Cancel')}
-            </button>
-            <button
-              onClick={handleCalStep}
-              disabled={busy}
-              className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 disabled:opacity-40 transition-colors min-h-[44px] 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 className="grid grid-cols-4 gap-1.5 mb-2">
+              {['7','8','9','backspace','4','5','6','.','1','2','3','0'].map((key) => (
+                <button
+                  key={key}
+                  onClick={() => numpadPress(key)}
+                  className={`rounded text-lg font-medium transition-colors min-h-[52px] active:scale-95 ${
+                    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>
+        )}
+
+        {/* Action buttons */}
+        <div className="flex gap-2 mt-auto">
+          <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-[44px]"
+          >
+            {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-[44px] 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>
   );
 }

+ 9 - 28
spoolbuddy/daemon/display_control.py

@@ -1,13 +1,12 @@
 """Display brightness and screen blanking control for SpoolBuddy kiosk.
 
-Brightness: controlled via sysfs /sys/class/backlight/*/brightness (DSI displays only).
-Blanking: uses vcgencmd display_power (RPi firmware-level, works for both HDMI and DSI
-without needing Wayland socket access).
+Brightness: DSI backlights are controlled via sysfs /sys/class/backlight/*/brightness.
+            HDMI brightness is handled by the frontend via CSS filter.
+Blanking:   Handled entirely by the frontend (CSS black overlay with touch-to-wake).
+            The daemon tracks idle state but does not control the physical display.
 """
 
 import logging
-import shutil
-import subprocess
 import time
 from pathlib import Path
 
@@ -20,7 +19,6 @@ class DisplayControl:
     def __init__(self):
         self._backlight_path = self._find_backlight()
         self._max_brightness = self._read_max_brightness()
-        self._has_vcgencmd = shutil.which("vcgencmd") is not None
         self._blank_timeout = 0  # seconds, 0 = disabled
         self._last_activity = time.monotonic()
         self._blanked = False
@@ -28,12 +26,7 @@ class DisplayControl:
         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")
-
-        if self._has_vcgencmd:
-            logger.info("vcgencmd available, screen blanking enabled")
-        else:
-            logger.warning("vcgencmd not found, screen blanking unavailable")
+            logger.info("No DSI backlight found, brightness control via frontend CSS")
 
     def _find_backlight(self) -> Path | None:
         if not BACKLIGHT_BASE.exists():
@@ -94,21 +87,9 @@ class DisplayControl:
             self._blank()
 
     def _blank(self):
-        if not self._has_vcgencmd:
-            return
-        try:
-            subprocess.run(["vcgencmd", "display_power", "0"], capture_output=True, timeout=5)
-            self._blanked = True
-            logger.debug("Screen blanked via vcgencmd")
-        except Exception as e:
-            logger.warning("Failed to blank screen: %s", e)
+        self._blanked = True
+        logger.debug("Screen idle timeout reached (frontend handles blanking)")
 
     def _unblank(self):
-        if not self._has_vcgencmd:
-            return
-        try:
-            subprocess.run(["vcgencmd", "display_power", "1"], capture_output=True, timeout=5)
-            self._blanked = False
-            logger.debug("Screen unblanked via vcgencmd")
-        except Exception as e:
-            logger.warning("Failed to unblank screen: %s", e)
+        self._blanked = False
+        logger.debug("Activity detected (frontend handles unblanking)")

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-BaDzX3Lp.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-CRHdzSgp.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-Dmdvp8GM.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-BgnIB0Ee.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CRHdzSgp.css">
+    <script type="module" crossorigin src="/assets/index-Dmdvp8GM.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BaDzX3Lp.css">
   </head>
   <body>
     <div id="root"></div>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor