Browse Source

Display Controls (brightness + blanking)

  Root cause: The daemon used wlopm for screen blanking, but wlopm was never installed. Additionally, the daemon runs as the spoolbuddy system user which has no access to
   the Wayland socket, so Wayland-based tools can't work.

  Fix in display_control.py:
  - Replaced wlopm --off/--on with vcgencmd display_power 0/1 — this is a Raspberry Pi firmware-level command that's pre-installed and works without Wayland socket access
  - Added shutil.which("vcgencmd") check at init to avoid repeated failures on non-RPi hardware
  - Added explicit PermissionError handling for brightness writes with a helpful message about the video group

  Fix in install.sh:
  - Added video group to the spoolbuddy service user's groups (was: gpio, spi, i2c → now: gpio, spi, i2c, video), which grants access to both vcgencmd and sysfs backlight
   files

  Scale Tab Numpad

  Root cause: On the 1024x600 kiosk screen (~376px available content height), the weight info card + numpad + action buttons exceeded the space, causing tiny buttons and
  overlapping.

  Fix in SpoolBuddySettingsPage.tsx:
  - Hide the weight info card during weight entry step (calStep !== 'weight'), reclaiming ~70px
  - Compact inline weight reading in the step header (small dot + monospace text) so users can still see the live scale value
  - Larger numpad buttons: min-h-[56px] with text-lg font size (was no min-height, text-sm)
  - Added active:scale-95 for tactile touch feedback
  - mt-auto on action buttons to push them to the bottom, preventing overlap
  - Removed the wrapping card around the calibration flow to save vertical padding
maziggy 2 months ago
parent
commit
28fda7b1a0

+ 0 - 3
frontend/src/App.tsx

@@ -29,8 +29,6 @@ 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({
   defaultOptions: {
     queries: {
@@ -152,7 +150,6 @@ function App() {
                 </Route>
               </Routes>
             </BrowserRouter>
-            <VirtualKeyboard />
           </AuthProvider>
         </QueryClientProvider>
       </ToastProvider>

+ 2 - 0
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -4,6 +4,7 @@ import { SpoolBuddyTopBar } from './SpoolBuddyTopBar';
 import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+import { VirtualKeyboard } from '../VirtualKeyboard';
 
 export function SpoolBuddyLayout() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
@@ -43,6 +44,7 @@ export function SpoolBuddyLayout() {
 
       <SpoolBuddyStatusBar alert={alert} />
       <SpoolBuddyBottomNav />
+      <VirtualKeyboard />
     </div>
   );
 }

+ 77 - 66
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -318,27 +318,29 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
 
   return (
     <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>
+      {/* Weight + info row — hidden during weight entry to maximize numpad space */}
+      {calStep !== 'weight' && (
+        <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>
-        {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>
+      )}
 
       {/* Status message */}
       {status && (
@@ -374,60 +376,69 @@ function ScaleTab({ device, weight, weightStable, rawAdc }: {
         </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">
+          {/* 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 the scale')
+                ? 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>
 
-            {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>
-                  ))}
+          {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>
+                ))}
+              </div>
+            </>
+          )}
 
-            <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>
+          {/* 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>
       )}

+ 26 - 5
spoolbuddy/daemon/display_control.py

@@ -1,6 +1,12 @@
-"""Display brightness and screen blanking control for SpoolBuddy kiosk."""
+"""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).
+"""
 
 import logging
+import shutil
 import subprocess
 import time
 from pathlib import Path
@@ -14,6 +20,7 @@ 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
@@ -23,6 +30,11 @@ class DisplayControl:
         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")
+
     def _find_backlight(self) -> Path | None:
         if not BACKLIGHT_BASE.exists():
             return None
@@ -53,6 +65,11 @@ class DisplayControl:
         try:
             (self._backlight_path / "brightness").write_text(str(value))
             logger.debug("Brightness set to %d%% (%d/%d)", pct, value, self._max_brightness)
+        except PermissionError:
+            logger.warning(
+                "Permission denied writing to %s/brightness. Ensure spoolbuddy user is in the 'video' group.",
+                self._backlight_path,
+            )
         except Exception as e:
             logger.warning("Failed to set brightness: %s", e)
 
@@ -77,17 +94,21 @@ class DisplayControl:
             self._blank()
 
     def _blank(self):
+        if not self._has_vcgencmd:
+            return
         try:
-            subprocess.run(["wlopm", "--off", "*"], capture_output=True, timeout=5)
+            subprocess.run(["vcgencmd", "display_power", "0"], capture_output=True, timeout=5)
             self._blanked = True
-            logger.debug("Screen blanked after idle timeout")
+            logger.debug("Screen blanked via vcgencmd")
         except Exception as e:
             logger.warning("Failed to blank screen: %s", e)
 
     def _unblank(self):
+        if not self._has_vcgencmd:
+            return
         try:
-            subprocess.run(["wlopm", "--on", "*"], capture_output=True, timeout=5)
+            subprocess.run(["vcgencmd", "display_power", "1"], capture_output=True, timeout=5)
             self._blanked = False
-            logger.debug("Screen unblanked")
+            logger.debug("Screen unblanked via vcgencmd")
         except Exception as e:
             logger.warning("Failed to unblank screen: %s", e)

+ 3 - 3
spoolbuddy/install/install.sh

@@ -360,13 +360,13 @@ create_spoolbuddy_user() {
         success "Service user created"
     fi
 
-    # Add to hardware access groups (gpio, spi, i2c)
-    for group in gpio spi i2c; do
+    # Add to hardware access groups (gpio, spi, i2c, video for backlight)
+    for group in gpio spi i2c video; do
         if getent group "$group" &>/dev/null; then
             usermod -aG "$group" "$SPOOLBUDDY_SERVICE_USER" 2>/dev/null || true
         fi
     done
-    success "User added to gpio, spi, i2c groups"
+    success "User added to gpio, spi, i2c, video groups"
 }
 
 download_spoolbuddy() {

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


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


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

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