From 18238cfeb247584b2060d808b69afda2001934b3 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 5 May 2026 13:50:08 +0200 Subject: [PATCH] fix(spoolbuddy): apply screen-blank timeout changes live without kiosk restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a new "Screen Blank Timeout" in SpoolBuddy Settings → Display didn't change actual blanking behaviour: whatever value was active when the kiosk last booted continued to fire. A user who started with the 10m preset and switched to 1m or "Off" still saw the screen blank at 10m. Cause: blanking is driven by swayidle, started once by spoolbuddy-idle.sh at labwc autostart with the timeout passed as a command-line argument. The script fetched blank_timeout from the backend exactly once at startup and swayidle has no runtime control surface for changing its timeout. The daemon's display.set_blank_timeout() updated an in-memory variable that was never reached by swayidle, so UI changes were silently discarded until the next kiosk restart. Fix: extend the wake FIFO protocol with a "reload-timeout N" message. The daemon writes it whenever set_blank_timeout() sees a value that differs from the current one (the very first call is suppressed because the watchdog already fetched the same value at its own startup — signalling there would just thrash swayidle on every cold boot). The script's FIFO loop is restructured around start_swayidle / stop_swayidle helpers and a case statement: wake → wlopm --on + arm a re-blank at the current timeout; reload-timeout N → kill running swayidle, set TIMEOUT=N, restart swayidle, wlopm --on so the user sees the change took effect even if the screen was already blanked. The script de-dupes too — a reload-timeout whose N matches the current value is a no-op. Going from any positive timeout to 0 ("Off") stops swayidle and doesn't restart it; going from 0 to a positive value starts a fresh swayidle. Both work without a kiosk restart. The script's main loop opens the FIFO read+write (exec 3<>"$WAKE_FIFO") so bash read never sees EOF when the daemon momentarily disconnects between writes. A cleanup trap on TERM/INT/HUP stops swayidle, removes the FIFO, and exits cleanly. --- CHANGELOG.md | 2 + spoolbuddy/daemon/display_control.py | 48 +++++++-- spoolbuddy/install/spoolbuddy-idle.sh | 128 ++++++++++++++++------- spoolbuddy/tests/test_display_control.py | 93 ++++++++++++++++ 4 files changed, 226 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e64e269ef..bfb78b4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ All notable changes to Bambuddy will be documented in this file. - **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`. ### Fixed +- **SpoolBuddy kiosk screen-blank timeout setting was ignored after the first save** (reported by maziggy) — Picking a new "Screen Blank Timeout" in SpoolBuddy Settings → Display didn't change the actual blanking behaviour: whatever value was active when the kiosk last booted continued to fire — a user who started with the 10 m preset and then switched to 1 m, 5 m, or "Off" still saw the screen blank at 10 m forever. Cause: blanking is driven by `swayidle`, started once by `spoolbuddy/install/spoolbuddy-idle.sh` at labwc autostart with the timeout passed as a command-line argument (`swayidle -w timeout $T 'wlopm --off' resume 'wlopm --on'`). The script fetched `blank_timeout` from the backend exactly once at startup and `swayidle` has no runtime control surface for changing its timeout. The Python daemon's `display.set_blank_timeout()` updated an in-memory variable on the daemon side that was only used for daemon-side idle bookkeeping (`tick()` log-line) and never reached `swayidle`, so UI changes were silently discarded until the next kiosk restart. Documented as such in the daemon's docstring (`display_control.py:5`: *"swayidle is the sole authority on screen blanking"*) — the architecture predicted the bug, the UX never matched. **Fix:** the wake FIFO at `/tmp/spoolbuddy-wake` now carries a second message in addition to `wake`: `reload-timeout N`. The daemon writes it whenever `set_blank_timeout()` is called with a value that differs from the current one (the very first call is suppressed because the watchdog already fetched the same value at its own startup — signalling there would just thrash `swayidle` on every cold start). The watchdog script's FIFO loop is restructured around `start_swayidle` / `stop_swayidle` helpers and a single `case` statement that dispatches on the message: `wake` → `wlopm --on` + arm a re-blank at the *current* timeout; `reload-timeout N` → kill the running `swayidle`, set `TIMEOUT=$N`, restart `swayidle`, and `wlopm --on` so the user sees the change took effect even if the screen was already blanked. The script de-dupes too — a `reload-timeout N` whose `N` matches the current value is a no-op, so the daemon's local de-dupe and the script's de-dupe both guard against thrash. Going from any positive timeout to `0` ("Off") correctly stops `swayidle` and never restarts it, going from `0` to a positive value starts a fresh `swayidle` — both work without a kiosk restart. The script's main loop opens the FIFO read+write (`exec 3<>"$WAKE_FIFO"`) so the bash `read` never sees EOF when the daemon momentarily disconnects between writes (without that, the loop would exit the first time the daemon closed its write end). A `cleanup` trap on `TERM/INT/HUP` stops `swayidle`, removes the FIFO, and exits cleanly. 7 new tests in `spoolbuddy/tests/test_display_control.py::TestDisplayControlFifoMessages` pin the daemon side of the protocol against a real FIFO in `tmp_path`: `wake()` writes the literal `wake\n` line; first `set_blank_timeout` is suppressed (script already has the right value); subsequent change emits `reload-timeout N\n`; identical-value calls don't signal; transitioning to `0` emits `reload-timeout 0\n` (covers "user picks Off after enabling"); negative inputs are clamped to `0` in the signal payload; missing-FIFO writes are silent no-ops (kiosk-not-running case). Also handles the SpoolBuddy `0` schema default — the first `set_blank_timeout(0)` call from a fresh daemon doesn't signal (init suppressed) so no spurious thrash on a never-configured device. + - **Archive 3MFs (and library file bytes) silently deleted from disk on every print completion** ([#1212](https://github.com/bambuman/bambuddy/issues/1212), reported by @abbasegbeyemi; matches private "file disappeared overnight" reports) — Reprint and View G-code on a freshly-completed archive returned 404 with no log line explaining why; the DB row was intact, the archive grid kept showing the entry, but `archive.file_path` pointed at a path that no longer existed on disk. Same shape independently reported by a daily-build user whose `.gcode.3mf` "disappeared by itself overnight" between Saturday's print and Monday morning's reprint attempt. Root cause was a regression introduced by [#1166](https://github.com/maziggy/bambuddy/issues/1166)'s cover-cache pre-population: the dispatch sites in `background_dispatch.py:692`, `background_dispatch.py:896`, and `print_scheduler.py:1897` started caching the **live archive copy** (and library file bytes for the Direct-Print flow) in the shared 3MF download cache so the `/cover` endpoint could skip a redundant FTP transfer to the printer mid-print. The cache itself was originally designed for transient downloads under `archive_dir/temp/` and `clear_3mf_cache(printer_id, delete_files=True)` — called from `on_print_complete` to keep that temp dir from accumulating — happily `unlink()`'d every cached path. Pre-#1166 every cached path was a temp file, so deletion was correct. Post-#1166 the cleanup was destroying user data: every print → archive 3mf cached → on print complete `clear_3mf_cache` walks the cache → `path.unlink()` on the actual archive copy. The `Path.exists()` guard inside `_maybe_unlink` masked the failure: the file existed at unlink time, so no exception, no warning, just silent destruction. The DB row remained, so the UI listing didn't change — only when the user tried to *act* on the archive (reprint / view-gcode / re-export) did the missing file surface as a 404. Affected every daily build since [`889c8bd8`](https://github.com/bambuman/bambuddy/commit/889c8bd8) (Apr 29). **Fix:** `clear_3mf_cache._maybe_unlink` in `backend/app/services/bambu_ftp.py` now refuses to `unlink()` any path outside `archive_dir/temp` — the cache dict is still cleared either way (so re-cache logic continues to work and the cover endpoint still hits a fresh path on the next print), only the on-disk delete is gated. Persistent locations — `archive//...`, `archive/unassigned/...` (VP-archived prints with `printer_id=None`), `library_files/...`, and any `is_external` library mount — survive intact. The dispatch sites that cache those paths are unchanged: it's correct for `/cover` to read straight from the live archive copy and avoid the redundant 36 MB FTP transfer; the only bug was the cleanup branch treating all cached paths as transient. Regression test `test_clear_does_not_delete_persistent_files` in `test_bambu_ftp.py` pins the contract end-to-end: an archive 3mf at `archive/1/.../...gcode.3mf`, a library 3mf at `library_files/...`, and a temp 3mf at `archive/temp/...` are all cached for the same printer; after `clear_3mf_cache(1)` runs, all three cache entries are dropped from the dict (so the cache state is consistent), but only the temp file is unlinked from disk — the archive and library files still exist. Two existing cache tests (`test_clear_by_printer_scoped`, `test_clear_without_deleting_files`) updated to put their fixtures under `archive_dir/temp` since that's now the only path the cleanup will touch. **Damage:** users on daily builds since Apr 29 with a `print → wait for completion → reprint or view-3mf later` workflow have been silently losing archive copies. Recovery for individual users: re-import the source 3mf from your slicer / NAS, or re-archive from the printer's FTP if the file is still there. Going forward the bytes are safe. - **MakerWorld P2S 3MFs failed to slice with "Param values in 3mf/config error: -1 not in range"** ([#1201](https://github.com/maziggy/bambuddy/issues/1201), reported by @inorichi) — Slicing any MakerWorld model sliced for the P2S (e.g. `https://makerworld.com/en/models/1958872`) bombed with `Slicer process failed (exit code 238)` and stderr listing `raft_first_layer_expansion: -1 not in range [0.0, 3.4e+38]` and `tree_support_wall_count: -1 not in range [0.0, 2.0]`. Root cause: BambuStudio writes `"-1"` into `Metadata/project_settings.config` for fields the user wants inherited from the parent process preset — the GUI handles this internally, but the headless CLI (orca-slicer-api / bambu-studio-api sidecar) runs `StaticPrintConfig`'s range validator against the embedded settings *before* the `--load-settings` overrides apply, so the sentinel `"-1"` trips the field's lower-bound check and the CLI exits non-zero before our profile triplet is ever consulted. The `slice_with_profiles` path failed; the fallback to `slice_without_profiles` (which uses embedded settings only) also failed because it reads the same `project_settings.config` and the same validator runs there too. Earlier in the codebase there's a `_strip_3mf_embedded_settings` function that tried to dodge this by removing the entire `project_settings.config` (plus `model_settings.config`, `slice_info.config`, `cut_information.xml`); that experiment was reverted because the strip broke `StaticPrintConfig` initialisation — silent exit-0, no `result.json`, no stderr, masked by the fallback retry which then produced wrong-printer output without telling anyone (the cautionary comment in `library.py:_run_slicer_with_fallback` records the lesson). **Fix is surgical:** new `_sanitize_project_settings_sentinels(zip_bytes)` opens the embedded config, removes only allowlisted keys when their value is exactly `"-1"`, and re-zips. Allowlist (`_PROJECT_SETTINGS_SENTINEL_KEYS`) starts with the two from this report (`raft_first_layer_expansion`, `tree_support_wall_count`) plus `prime_tower_brim_width` (a known sentinel cited in the strip-experiment comment block from earlier reports). Other fields — including non-allowlisted keys that happen to hold `"-1"` (e.g. `z_offset` set to `-1` deliberately by a user) — are left untouched, so a blanket "-1 strip" can't silently corrupt legitimate negative values. The sanitiser runs before *both* the profile-driven path and the embedded-settings fallback, since both fail on the same input. Defensive fallbacks: returns the original bytes unchanged when the input isn't a valid zip, doesn't contain `project_settings.config`, has no allowlisted sentinels present, the JSON is malformed, or the config root isn't a dict — so the caller can pass the result on without further checks. Geometry, thumbnails, color, multi-part data, and every other zip entry round-trip byte-identical (the previous full-strip experiment's failure mode can't reoccur). 13 new unit tests in `test_project_settings_sentinel_sanitiser.py` pin the contract: each allowlisted key removed when value is `"-1"` (parametrised across the allowlist); multiple sentinels removed at once; allowlisted key with legitimate non-sentinel value (`"0"`) preserved; non-allowlisted key holding `"-1"` (`z_offset`) preserved; identity return when nothing needs sanitising; array-form values (per-filament/per-extruder lists) left alone (v1 handles scalar strings only, expand later if needed); other zip entries (model_settings.config, slice_info.config, _rels metadata, geometry) all preserved with byte-identical content; non-zip input passes through; missing `project_settings.config` passes through; malformed JSON passes through; non-dict JSON root passes through. **Adding new sentinel keys:** if a future report surfaces another field name in the slicer's `: -1 not in range [...]` error, add the field to `_PROJECT_SETTINGS_SENTINEL_KEYS` — the rest of the code stays unchanged. diff --git a/spoolbuddy/daemon/display_control.py b/spoolbuddy/daemon/display_control.py index ad2cca12d..9355e9ffd 100644 --- a/spoolbuddy/daemon/display_control.py +++ b/spoolbuddy/daemon/display_control.py @@ -6,7 +6,11 @@ Blanking: swayidle is the sole authority on screen blanking (idle timeout → wlopm --off, touch → wlopm --on). The daemon wakes the display by writing to a FIFO that the idle watchdog monitors — the watchdog runs inside the Wayland session and calls wlopm --on on behalf of - the daemon. + the daemon. The same FIFO carries "reload-timeout N" lines: when + the user changes the blank-timeout setting in the UI, the daemon + picks up the new value over the heartbeat and signals the watchdog + to kill+restart swayidle with the new timeout, so changes take + effect live without a kiosk restart. """ import logging @@ -26,6 +30,7 @@ class DisplayControl: self._backlight_path = self._find_backlight() self._max_brightness = self._read_max_brightness() self._blank_timeout = 0 # seconds, 0 = disabled + self._timeout_initialized = False self._last_activity = time.monotonic() self._blanked = False @@ -73,8 +78,24 @@ class DisplayControl: 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) + """Set screen blank timeout in seconds. 0 = disabled. + + On every change after the first call, signals the idle watchdog + (spoolbuddy-idle.sh) to restart swayidle with the new value. + Without this, swayidle keeps running with whatever timeout it + was started with at autostart and UI changes only take effect + after a kiosk restart. + + The first call (during daemon startup) is suppressed because the + watchdog already fetched the same value from the backend at its + own startup; signalling here would just thrash swayidle. + """ + new_timeout = max(0, seconds) + changed = new_timeout != self._blank_timeout + self._blank_timeout = new_timeout + if changed and self._timeout_initialized: + self._signal_reload_timeout(new_timeout) + self._timeout_initialized = True def wake(self): """Wake screen on activity (NFC tag, scale weight change). @@ -100,20 +121,31 @@ class DisplayControl: def _signal_wake(self) -> None: """Write to the wake FIFO to request display power-on.""" + if self._write_fifo(b"wake\n"): + logger.info("Wake signal sent via FIFO") + + def _signal_reload_timeout(self, seconds: int) -> None: + """Tell the idle watchdog to apply a new timeout to swayidle.""" + if self._write_fifo(f"reload-timeout {seconds}\n".encode()): + logger.info("Reload-timeout signal sent (timeout=%ds)", seconds) + + def _write_fifo(self, payload: bytes) -> bool: + """Best-effort write to the wake FIFO. Returns True on success.""" if not WAKE_FIFO.exists(): - return + return False try: # Verify it's actually a FIFO, not a regular file if not stat.S_ISFIFO(WAKE_FIFO.stat().st_mode): - return + return False # Open non-blocking so we don't hang if no reader is attached fd = os.open(str(WAKE_FIFO), os.O_WRONLY | os.O_NONBLOCK) try: - os.write(fd, b"wake\n") - logger.info("Wake signal sent via FIFO") + os.write(fd, payload) + return True finally: os.close(fd) except OSError as e: # ENXIO = no reader on the FIFO (idle script not running) — expected if e.errno != 6: # ENXIO - logger.debug("Wake FIFO write failed: %s", e) + logger.debug("FIFO write failed: %s", e) + return False diff --git a/spoolbuddy/install/spoolbuddy-idle.sh b/spoolbuddy/install/spoolbuddy-idle.sh index a10024f1b..7291e968f 100755 --- a/spoolbuddy/install/spoolbuddy-idle.sh +++ b/spoolbuddy/install/spoolbuddy-idle.sh @@ -4,8 +4,13 @@ # 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. +# whatever the user picked in SpoolBuddy Settings → Display. +# +# Changes made in the UI are applied live: the daemon writes a +# "reload-timeout N" line to /tmp/spoolbuddy-wake whenever it sees a new +# value over the heartbeat, and the FIFO loop below kills the current +# swayidle and starts a fresh one with the new timeout. No kiosk restart +# is required. # # Runs in labwc's autostart file as the kiosk user — needs access to # WAYLAND_DISPLAY, which it inherits from the parent labwc process. @@ -85,45 +90,94 @@ if [ -n "$BACKEND_URL" ] && [ -n "$API_KEY" ] && [ -n "$DEVICE_ID" ]; then fi fi -# FIFO for the SpoolBuddy daemon to request display wake from outside the -# Wayland session (NFC tag scan, scale weight change). The daemon writes -# "wake\n" to this pipe; the monitor loop below calls wlopm --on. +# FIFO for the SpoolBuddy daemon to talk to this watchdog from outside the +# Wayland session. Two messages are understood: +# wake — turn the display on (NFC tag scan, scale weight change) +# reload-timeout N — kill swayidle and restart it with timeout=N WAKE_FIFO="/tmp/spoolbuddy-wake" rm -f "$WAKE_FIFO" mkfifo -m 622 "$WAKE_FIFO" echo "wake FIFO created at $WAKE_FIFO" -if [ "$TIMEOUT" -le 0 ]; then - # Blanking explicitly disabled — just monitor the wake FIFO so NFC/scale - # wake still works even without swayidle. - echo "timeout<=0, monitoring wake FIFO only (no swayidle)" - while read -r _ < "$WAKE_FIFO"; do - wlopm --on "$OUTPUT" 2>/dev/null || true - done - exit 0 -fi - -echo "starting swayidle with timeout=$TIMEOUT output=$OUTPUT" -swayidle -w \ - timeout "$TIMEOUT" "wlopm --off $OUTPUT" \ - resume "wlopm --on $OUTPUT" & -SWAYIDLE_PID=$! - -# Monitor wake FIFO — when the daemon writes to it, turn the display on -# and schedule a re-blank after TIMEOUT seconds (swayidle doesn't know about -# FIFO wakes so it won't re-blank on its own). +SWAYIDLE_PID="" REBLANK_PID="" -while read -r _ < "$WAKE_FIFO"; do - wlopm --on "$OUTPUT" 2>/dev/null || true - # Cancel any pending re-blank timer, then start a new one - [ -n "$REBLANK_PID" ] && kill "$REBLANK_PID" 2>/dev/null || true - (sleep "$TIMEOUT" && wlopm --off "$OUTPUT" 2>/dev/null) & - REBLANK_PID=$! -done & -FIFO_PID=$! -# If either process exits, clean up and exit. -wait -n "$SWAYIDLE_PID" "$FIFO_PID" 2>/dev/null -echo "child exited, cleaning up" -kill "$SWAYIDLE_PID" "$FIFO_PID" 2>/dev/null || true -rm -f "$WAKE_FIFO" +start_swayidle() { + [ "$TIMEOUT" -gt 0 ] || return 0 + swayidle -w \ + timeout "$TIMEOUT" "wlopm --off $OUTPUT" \ + resume "wlopm --on $OUTPUT" & + SWAYIDLE_PID=$! + echo "swayidle started (pid=$SWAYIDLE_PID, timeout=$TIMEOUT, output=$OUTPUT)" +} + +stop_swayidle() { + if [ -n "$SWAYIDLE_PID" ]; then + kill "$SWAYIDLE_PID" 2>/dev/null || true + wait "$SWAYIDLE_PID" 2>/dev/null || true + SWAYIDLE_PID="" + fi + if [ -n "$REBLANK_PID" ]; then + kill "$REBLANK_PID" 2>/dev/null || true + REBLANK_PID="" + fi +} + +cleanup() { + stop_swayidle + rm -f "$WAKE_FIFO" + exit 0 +} +trap cleanup TERM INT HUP + +start_swayidle + +# Open the FIFO read+write so EOF never arrives even when the daemon +# (the writer) momentarily disconnects between messages — without this, +# `read` would return immediately the first time the daemon closes its +# write end and the loop would spin. +exec 3<>"$WAKE_FIFO" + +while IFS= read -r line <&3; do + case "$line" in + wake) + wlopm --on "$OUTPUT" 2>/dev/null || true + # Cancel any pending re-blank timer, then start a new one + # at the *current* timeout (swayidle doesn't know about + # FIFO wakes so it won't re-blank on its own). + [ -n "$REBLANK_PID" ] && kill "$REBLANK_PID" 2>/dev/null || true + REBLANK_PID="" + if [ "$TIMEOUT" -gt 0 ]; then + (sleep "$TIMEOUT" && wlopm --off "$OUTPUT" 2>/dev/null) & + REBLANK_PID=$! + fi + ;; + reload-timeout\ *) + new_timeout="${line#reload-timeout }" + # Validate: must be a non-negative integer. + if [ "$new_timeout" -eq "$new_timeout" ] 2>/dev/null && [ "$new_timeout" -ge 0 ]; then + if [ "$new_timeout" != "$TIMEOUT" ]; then + echo "reload-timeout: $TIMEOUT -> $new_timeout" + stop_swayidle + TIMEOUT="$new_timeout" + start_swayidle + # Bring the display back on so the user sees the + # change took effect (a setting saved while the + # screen was already blanked would otherwise look + # ignored until the next touch). + wlopm --on "$OUTPUT" 2>/dev/null || true + fi + else + echo "ignoring invalid reload-timeout payload: $new_timeout" + fi + ;; + '') + : # ignore empty lines (e.g. opening the FIFO with no payload) + ;; + *) + echo "unknown FIFO message: $line" + ;; + esac +done + +cleanup diff --git a/spoolbuddy/tests/test_display_control.py b/spoolbuddy/tests/test_display_control.py index 155797c67..aae8d49c9 100644 --- a/spoolbuddy/tests/test_display_control.py +++ b/spoolbuddy/tests/test_display_control.py @@ -1,5 +1,6 @@ """Tests for daemon.display_control — DisplayControl brightness and blanking.""" +import os import time import pytest @@ -174,3 +175,95 @@ class TestDisplayControlBlanking: time.sleep(0.01) display.wake() assert display._last_activity > old_time + + +class TestDisplayControlFifoMessages: + """The wake FIFO carries two messages: `wake` and `reload-timeout N`. + + These tests pin both — they're the only way the daemon can talk to + the idle watchdog (spoolbuddy-idle.sh) running in the Wayland session. + Regression target: a one-shot swayidle started with a stale timeout + value would never pick up UI changes without these signals. + """ + + @pytest.fixture + def display_with_fifo(self, monkeypatch, tmp_path): + import daemon.display_control as dc_mod + + empty_dir = tmp_path / "backlight" + empty_dir.mkdir() + monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir) + + fifo_path = tmp_path / "spoolbuddy-wake" + os.mkfifo(str(fifo_path), 0o622) + monkeypatch.setattr(dc_mod, "WAKE_FIFO", fifo_path) + + # Hold a non-blocking reader open so the daemon's writes don't hit ENXIO. + reader_fd = os.open(str(fifo_path), os.O_RDONLY | os.O_NONBLOCK) + try: + yield dc_mod.DisplayControl(), reader_fd + finally: + os.close(reader_fd) + + @staticmethod + def _drain(fd: int) -> bytes: + """Read whatever is queued on the FIFO without blocking.""" + try: + return os.read(fd, 4096) + except BlockingIOError: + return b"" + + def test_wake_writes_wake_line(self, display_with_fifo): + dc, reader_fd = display_with_fifo + dc.wake() + assert self._drain(reader_fd) == b"wake\n" + + def test_first_set_blank_timeout_does_not_signal(self, display_with_fifo): + """The watchdog already fetched this value at its own startup — + signalling here would just thrash swayidle for nothing.""" + dc, reader_fd = display_with_fifo + dc.set_blank_timeout(300) + assert self._drain(reader_fd) == b"" + assert dc._blank_timeout == 300 + + def test_subsequent_change_signals_reload(self, display_with_fifo): + dc, reader_fd = display_with_fifo + dc.set_blank_timeout(300) # init — no signal + dc.set_blank_timeout(60) + assert self._drain(reader_fd) == b"reload-timeout 60\n" + + def test_same_value_does_not_signal(self, display_with_fifo): + dc, reader_fd = display_with_fifo + dc.set_blank_timeout(300) + dc.set_blank_timeout(300) + assert self._drain(reader_fd) == b"" + + def test_disable_after_enable_signals_zero(self, display_with_fifo): + """Going from "blanking on" to "blanking off" must reach the watchdog + so it can stop swayidle — otherwise the screen keeps blanking even + after the user picks 'Off'.""" + dc, reader_fd = display_with_fifo + dc.set_blank_timeout(300) # init + dc.set_blank_timeout(0) + assert self._drain(reader_fd) == b"reload-timeout 0\n" + + def test_negative_clamped_to_zero_in_signal(self, display_with_fifo): + dc, reader_fd = display_with_fifo + dc.set_blank_timeout(300) # init + dc.set_blank_timeout(-5) + assert self._drain(reader_fd) == b"reload-timeout 0\n" + + def test_signal_no_op_when_fifo_missing(self, monkeypatch, tmp_path): + """No watchdog running = no FIFO. Writes must not raise.""" + import daemon.display_control as dc_mod + + empty_dir = tmp_path / "backlight" + empty_dir.mkdir() + monkeypatch.setattr(dc_mod, "BACKLIGHT_BASE", empty_dir) + monkeypatch.setattr(dc_mod, "WAKE_FIFO", tmp_path / "no-such-fifo") + + dc = dc_mod.DisplayControl() + dc.set_blank_timeout(300) + dc.set_blank_timeout(60) # would signal if FIFO existed + dc.wake() + # No assertion needed — surviving without raising is the contract.