Parcourir la source

● fix(spoolbuddy): actually power off HDMI on idle instead of CSS overlay (#937)

  The SpoolBuddy kiosk's "screen blank timeout" setting only painted a
  black CSS overlay over the browser window — the HDMI panel's backlight
  stayed on indefinitely, wasting power and risking burn-in on
  OLED/LED panels.

  Move blanking down to the OS layer:

  - install.sh now installs swayidle + wlopm + jq and rewrites labwc's
    autostart to launch a new spoolbuddy-idle.sh watchdog instead of the
    old `wlr-randr --on` keep-alive loop.
  - The watchdog sources /opt/bambuddy/spoolbuddy/.env, derives device_id
    from the first non-loopback MAC (same algorithm as daemon/config.py),
    fetches the configured blank_timeout from the backend once on boot,
    and execs `swayidle -w timeout $T 'wlopm --off HDMI-A-1' resume
    'wlopm --on HDMI-A-1'`. Touch/keypress wakes via labwc's input event
    path. timeout=0 skips swayidle entirely so existing installs that
    never picked a timeout keep their current always-on behavior.
  - New GET /api/v1/spoolbuddy/devices/{id}/display endpoint returns the
    current brightness + blank_timeout. Gated on INVENTORY_UPDATE (same
    level the daemon heartbeat key already uses) so existing SpoolBuddy
    API keys work without extra permissions.
  - SpoolBuddyLayout drops blanked state, the blank timer, activity
    listeners, resetActivity, and the CSS overlay. Runtime updates to
    the timeout take effect on next kiosk/browser restart; default for
    newly-enabled blanking is 300 seconds.
maziggy il y a 1 mois
Parent
commit
c4ebe5a70c

+ 3 - 0
CHANGELOG.md

@@ -13,6 +13,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **Printers Page Search and Filters** ([#852](https://github.com/maziggy/bambuddy/issues/852)) — The Printers page now has a live search bar and two filter dropdowns (status and location) to make finding specific printers in large setups easier, especially on mobile where Ctrl+F is impractical. Search matches printer name, model, location, and serial number (case-insensitive, whitespace-trimmed) and has a clear button. The status filter covers All / Printing / Paused / Idle / Finished / Error / Offline and is reactive to WebSocket status updates via a React Query cache subscription — so a print finishing while "Printing" is selected immediately removes the printer from the filtered list. The location filter is only shown when at least one printer has a location configured. All three filters are combinable; the controls are hidden when no printers are configured yet; and an empty-state message appears when no printer matches the current search/filters. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
 - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
 
+### Changed
+- **SpoolBuddy Kiosk LCD Now Powers Off on Idle** ([#937](https://github.com/maziggy/bambuddy/issues/937)) — The SpoolBuddy kiosk's "screen blank timeout" setting previously only painted a black CSS overlay over the browser window; the HDMI panel's backlight stayed on indefinitely, wasting power and letting OLED/LED panels burn in. The blanking path is now moved down to the OS layer: the install script installs `swayidle` and `wlopm`, and labwc's autostart launches a new watchdog (`spoolbuddy/install/spoolbuddy-idle.sh`) that queries the backend once on boot for the device's `display_blank_timeout` and hands it to `swayidle`, which powers HDMI off via `wlopm --off HDMI-A-1` after the configured idle period and powers it back on via `wlopm --on` when labwc delivers any input event (touch, keypress). The redundant CSS overlay and its pointer/keyboard listeners have been removed from `SpoolBuddyLayout` — one source of truth now. Screen blanking is opt-in: `display_blank_timeout=0` (the default) skips launching swayidle entirely and the display stays on forever, preserving current behavior for users who didn't pick a timeout. The default for users who newly enable blanking is 300 seconds. Changes made to the timeout in SpoolBuddy Settings → Display take effect on the next kiosk restart — tap Quick Menu → Restart Browser to apply without a full reboot. A new `GET /api/v1/spoolbuddy/devices/{device_id}/display` endpoint (gated on `inventory:update`, same as the existing `PUT` and heartbeat endpoints) is what the kiosk-side watchdog reads, so no new permissions are required on the device's API key. Thanks to @TravisWilder for reporting.
+
 ### Fixed
 - **Energy Snapshot Capture Crashes on PostgreSQL** — With an external PostgreSQL database configured, the hourly smart-plug energy snapshot loop (introduced with the #941 fix) logged `asyncpg.DataError: invalid input for query argument $2: ... can't subtract offset-naive and offset-aware datetimes` every hour and failed to persist any snapshots, so date-filtered energy statistics in total-consumption mode stayed empty on Postgres installs. The engine already had a `before_cursor_execute` hook that strips `tzinfo` from bound datetime parameters before they reach asyncpg (the `smart_plug_energy_snapshots.recorded_at` column is `TIMESTAMP WITHOUT TIME ZONE` to match the rest of the schema), but the hook only stripped datetimes one level deep — when SQLAlchemy's `insertmanyvalues` feature batched multiple snapshot rows into a single `INSERT ... SELECT FROM (VALUES ...)` statement, parameters arrived as nested containers (lists of tuples, or a list inside an outer container) and the inner datetimes slipped through untouched. The hook now recursively walks any nesting of dict/list/tuple and strips `tzinfo` at any depth, so every parameter shape SQLAlchemy may use is handled. SQLite installs were never affected (SQLite ignores tzinfo entirely).
 - **Wrong Filament Color Name Shown on Printer Tab AMS Popup** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — PLA Translucent Cherry Pink (and other colors outside a small hand-maintained list) appeared as "Scarlet Red" on the Printer tab AMS slot popup, and was also auto-provisioned into the inventory under the wrong name on the first RFID read. Root cause: both the backend spool auto-provisioner and the frontend AMS popup resolved color names by looking up the Bambu `tray_id_name` code (e.g. `A17-R1`) in a hardcoded table, and when the exact code wasn't listed they fell back to a suffix-only lookup (`R1 → Scarlet Red`). The suffix half of that code is **not** globally unique across material families — `A17-R1` is PLA Translucent Cherry Pink, while `A01-R1` is PLA Matte Scarlet Red — so the fallback was structurally guaranteed to produce wrong names for any color the hand-maintained list didn't happen to cover. The resolver has been rewritten to use the existing `color_catalog` table (seeded from `catalog_defaults.py` plus the FilamentColors.xyz sync) as the single source of truth. Backend lookup is now by hex color against the catalog; the frontend fetches a compact `{hex: name}` map once per session via a new `GET /api/inventory/colors/map` endpoint (available to any authenticated user, not gated on `inventory:read`), stores it in a `ColorCatalogProvider` context, and uses it for all `getColorName()` calls. The hardcoded tables in `backend/app/core/bambu_colors.py`, `frontend/src/utils/colors.ts`, and `frontend/src/pages/PrintersPage.tsx` have been removed entirely. Existing spools that were auto-created with a wrong name before this fix need to be renamed manually — the fix only affects new auto-provisioning and live display. Thanks to @lightmaster for reporting.

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

@@ -630,6 +630,28 @@ async def get_calibration(
 # --- Display settings ---
 
 
+@router.get("/devices/{device_id}/display")
+async def get_display_settings(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Read current display brightness and screen blank timeout for a device.
+
+    Used by the SpoolBuddy kiosk idle watchdog on autostart to configure
+    swayidle with the same timeout the user picked in the UI, without having
+    to wait for the daemon heartbeat to arrive first.
+    """
+    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")
+    return {
+        "brightness": device.display_brightness,
+        "blank_timeout": device.display_blank_timeout,
+    }
+
+
 @router.put("/devices/{device_id}/display")
 async def update_display_settings(
     device_id: str,

+ 21 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -890,6 +890,27 @@ class TestDisplayEndpoints:
         )
         assert resp.status_code == 422  # Validation error: brightness > 100
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_display_settings(self, async_client: AsyncClient, device_factory):
+        """The kiosk idle watchdog (install/spoolbuddy-idle.sh) reads this
+        endpoint on autostart to configure swayidle with the user-selected
+        blank timeout before launching. See issue #937."""
+        await device_factory(device_id="sb-disp-get", display_brightness=60, display_blank_timeout=450)
+
+        resp = await async_client.get(f"{API}/devices/sb-disp-get/display")
+
+        assert resp.status_code == 200
+        data = resp.json()
+        assert data["brightness"] == 60
+        assert data["blank_timeout"] == 450
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_display_unknown_device_404(self, async_client: AsyncClient):
+        resp = await async_client.get(f"{API}/devices/ghost/display")
+        assert resp.status_code == 404
+
 
 # ============================================================================
 # Update endpoints

+ 0 - 2
frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx

@@ -88,8 +88,6 @@ const mockOutletContext = {
   setAlert: vi.fn(),
   displayBrightness: 80,
   setDisplayBrightness: vi.fn(),
-  displayBlankTimeout: 300,
-  setDisplayBlankTimeout: vi.fn(),
 };
 
 function OutletWrapper() {

+ 5 - 44
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -18,10 +18,7 @@ export function SpoolBuddyLayout() {
   useColorCatalogVersion();
   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 [displayBrightness, setDisplayBrightness] = useState(100);
-  const [displayBlankTimeout, setDisplayBlankTimeout] = useState(0);
-  const lastActivityRef = useRef(Date.now());
   const { i18n } = useTranslation();
   const navigate = useNavigate();
   const location = useLocation();
@@ -56,7 +53,6 @@ export function SpoolBuddyLayout() {
   useEffect(() => {
     if (device && !initializedRef.current) {
       setDisplayBrightness(device.display_brightness);
-      setDisplayBlankTimeout(device.display_blank_timeout);
       initializedRef.current = true;
     }
   }, [device]);
@@ -98,44 +94,20 @@ export function SpoolBuddyLayout() {
     }
   }, [effectiveDeviceOnline, updateCheck?.update_available, updateCheck?.latest_version]);
 
-  // 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]);
-
-  // Auto-navigate to dashboard when a NEW tag is detected (transition from no-tag to tag)
+  // Auto-navigate to dashboard when a NEW tag is detected (transition from no-tag to tag).
+  // Blanking itself is handled by swayidle/wlopm at the OS level on the kiosk device —
+  // when the HDMI output powers off and the user taps the screen, labwc delivers the
+  // input event to swayidle's `resume` command which re-powers HDMI. See issue #937.
   const tagDetected = Boolean(sbState.matchedSpool || sbState.unknownTagUid);
   const prevTagDetected = useRef(false);
   useEffect(() => {
     if (tagDetected && !prevTagDetected.current) {
-      resetActivity();
       if (location.pathname !== '/spoolbuddy') {
         navigate('/spoolbuddy');
       }
     }
     prevTagDetected.current = tagDetected;
-  }, [tagDetected, location.pathname, navigate, resetActivity]);
-
-  // Screen blank timer
-  useEffect(() => {
-    if (displayBlankTimeout <= 0) return;
-    const interval = setInterval(() => {
-      if (Date.now() - lastActivityRef.current >= displayBlankTimeout * 1000) {
-        setBlanked(true);
-      }
-    }, 1000);
-    return () => clearInterval(interval);
-  }, [displayBlankTimeout]);
+  }, [tagDetected, location.pathname, navigate]);
 
   // Online printers list for swipe-to-switch
   const { data: printers = [] } = useQuery({
@@ -245,7 +217,6 @@ export function SpoolBuddyLayout() {
           <Outlet context={{
             selectedPrinterId, setSelectedPrinterId, sbState: sbStateForUi, setAlert,
             displayBrightness, setDisplayBrightness,
-            displayBlankTimeout, setDisplayBlankTimeout,
           }} />
         </main>
 
@@ -261,14 +232,6 @@ export function SpoolBuddyLayout() {
         deviceId={device?.device_id ?? null}
         deviceOnline={effectiveDeviceOnline}
       />
-
-      {/* Screen blank overlay — touch to wake */}
-      {blanked && (
-        <div
-          className="fixed inset-0 bg-black z-[9999]"
-          onPointerDown={(e) => { e.stopPropagation(); resetActivity(); }}
-        />
-      )}
     </>
   );
 }
@@ -281,6 +244,4 @@ export interface SpoolBuddyOutletContext {
   setAlert: (alert: { type: 'warning' | 'error' | 'info'; message: string } | null) => void;
   displayBrightness: number;
   setDisplayBrightness: (brightness: number) => void;
-  displayBlankTimeout: number;
-  setDisplayBlankTimeout: (timeout: number) => void;
 }

+ 2 - 5
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -224,10 +224,9 @@ function DeviceTab({ device }: { device: SpoolBuddyDevice }) {
 
 // --- Display Tab ---
 
-function DisplayTab({ device, onBrightnessChange, onBlankTimeoutChange }: {
+function DisplayTab({ device, onBrightnessChange }: {
   device: SpoolBuddyDevice;
   onBrightnessChange: (value: number) => void;
-  onBlankTimeoutChange: (value: number) => void;
 }) {
   const { t } = useTranslation();
   const [brightness, setBrightness] = useState(device.display_brightness);
@@ -265,7 +264,6 @@ function DisplayTab({ device, onBrightnessChange, onBlankTimeoutChange }: {
 
   const handleBlankTimeoutChange = (value: number) => {
     setBlankTimeout(value);
-    onBlankTimeoutChange(value);  // Instant layout update
     sendDisplayUpdate(brightness, value);
   };
 
@@ -950,7 +948,7 @@ function SystemTab({ device }: { device: SpoolBuddyDevice }) {
 type SettingsTab = 'device' | 'display' | 'scale' | 'updates' | 'system';
 
 export function SpoolBuddySettingsPage() {
-  const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext<SpoolBuddyOutletContext>();
+  const { sbState, setDisplayBrightness } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
   const [activeTab, setActiveTab] = useState<SettingsTab>('device');
 
@@ -1012,7 +1010,6 @@ export function SpoolBuddySettingsPage() {
               <DisplayTab
                 device={device}
                 onBrightnessChange={setDisplayBrightness}
-                onBlankTimeoutChange={setDisplayBlankTimeout}
               />
             )}
             {activeTab === 'scale' && (

+ 6 - 3
spoolbuddy/install/install.sh

@@ -947,7 +947,7 @@ setup_kiosk() {
         dpkg-divert --local --rename --add /usr/sbin/update-initramfs >/dev/null 2>&1 || true
         ln -sf /bin/true /usr/sbin/update-initramfs
     fi
-    run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr
+    run_with_progress "Installing kiosk packages" apt-get install -y labwc chromium plymouth wlr-randr swayidle wlopm jq
     # Restore real update-initramfs
     if dpkg-divert --list /usr/sbin/update-initramfs 2>/dev/null | grep -q local; then
         rm -f /usr/sbin/update-initramfs
@@ -1183,8 +1183,11 @@ EOF
 # Force 1024x600 (panel doesn't advertise this natively)
 wlr-randr --output HDMI-A-1 --custom-mode 1024x600@60 &
 
-# Prevent display blanking (labwc <0.8 lacks screenBlankTimeout config support)
-(while true; do wlr-randr --output HDMI-A-1 --on 2>/dev/null; sleep 60; done) &
+# Idle watchdog: powers off HDMI via wlopm after the configured inactivity
+# timeout (SpoolBuddy Settings → Display → Screen blank timeout). Reads the
+# current value from the backend on startup; UI changes take effect on the
+# next reboot / kiosk restart.
+$INSTALL_PATH/spoolbuddy/install/spoolbuddy-idle.sh &
 
 # Launch Chromium via helper that resolves URL from spoolbuddy/.env
 $kiosk_launcher &

+ 66 - 0
spoolbuddy/install/spoolbuddy-idle.sh

@@ -0,0 +1,66 @@
+#!/bin/bash
+# SpoolBuddy kiosk display idle watchdog.
+#
+# Powers the HDMI output off via wlopm after the configured inactivity
+# timeout, driven by swayidle inside the labwc Wayland session. The timeout
+# value is fetched once from the Bambuddy backend on startup so it matches
+# whatever the user picked in SpoolBuddy Settings → Display. Changes made
+# in the UI take effect on the next reboot / kiosk restart.
+#
+# Runs in labwc's autostart file as the kiosk user — needs access to
+# WAYLAND_DISPLAY, which it inherits from the parent labwc process.
+
+set -u
+
+DEFAULT_TIMEOUT=300
+ENV_FILE="${SPOOLBUDDY_ENV_FILE:-/opt/bambuddy/spoolbuddy/.env}"
+OUTPUT="${SPOOLBUDDY_DISPLAY_OUTPUT:-HDMI-A-1}"
+
+if [ -r "$ENV_FILE" ]; then
+    set -a
+    # shellcheck disable=SC1090
+    . "$ENV_FILE"
+    set +a
+fi
+
+BACKEND_URL="${SPOOLBUDDY_BACKEND_URL:-}"
+API_KEY="${SPOOLBUDDY_API_KEY:-}"
+DEVICE_ID="${SPOOLBUDDY_DEVICE_ID:-}"
+
+# Derive device_id from the first non-loopback NIC MAC address, the same
+# algorithm daemon/config.py uses so installs without an explicit
+# SPOOLBUDDY_DEVICE_ID still match.
+if [ -z "$DEVICE_ID" ]; then
+    for iface in $(ls -1 /sys/class/net/ 2>/dev/null | sort); do
+        [ "$iface" = "lo" ] && continue
+        addr_file="/sys/class/net/$iface/address"
+        [ -r "$addr_file" ] || continue
+        mac=$(tr -d ':' < "$addr_file" 2>/dev/null)
+        if [ -n "$mac" ] && [ "$mac" != "000000000000" ]; then
+            DEVICE_ID="sb-$mac"
+            break
+        fi
+    done
+fi
+
+TIMEOUT="$DEFAULT_TIMEOUT"
+if [ -n "$BACKEND_URL" ] && [ -n "$API_KEY" ] && [ -n "$DEVICE_ID" ]; then
+    response=$(curl -fsS --max-time 10 \
+        -H "Authorization: Bearer $API_KEY" \
+        "$BACKEND_URL/api/v1/spoolbuddy/devices/$DEVICE_ID/display" 2>/dev/null || true)
+    if [ -n "$response" ]; then
+        fetched=$(printf '%s' "$response" | jq -r '.blank_timeout // empty' 2>/dev/null || true)
+        if [ -n "$fetched" ] && [ "$fetched" -eq "$fetched" ] 2>/dev/null; then
+            TIMEOUT="$fetched"
+        fi
+    fi
+fi
+
+if [ "$TIMEOUT" -le 0 ]; then
+    # Blanking explicitly disabled — don't launch swayidle at all.
+    exec sleep infinity
+fi
+
+exec swayidle -w \
+    timeout "$TIMEOUT" "wlopm --off $OUTPUT" \
+    resume "wlopm --on $OUTPUT"

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-ZFxtu4Z1.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DlTZxH8V.js"></script>
+    <script type="module" crossorigin src="/assets/index-ZFxtu4Z1.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Czpqfgna.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff