mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
fix(spoolbuddy): apply screen-blank timeout changes live without kiosk restart
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.
This commit is contained in:
@@ -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/<printer_id>/...`, `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 `<field>: -1 not in range [...]` error, add the field to `_PROJECT_SETTINGS_SENTINEL_KEYS` — the rest of the code stays unchanged.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user