Configuration
dormant configuration is a TOML file. Optional keys list their defaults here; required keys are marked required. Unknown keys are rejected at startup (strict mode) or warned about (warn mode — default).
Schema version
config_version = 1
Required. Must be 1. This gates backward compatibility — bump when making breaking schema changes.
[daemon] — daemon-level settings
| Key | Type | Default | Description |
|---|---|---|---|
startup_holdoff | duration | "30s" | Wait after startup before acting (allows sensors to stabilize) |
stale_sensor_timeout | duration | "5m" | How long a sensor can go silent before considered stale |
log_level | string | "info" | Tracing log level: "trace", "debug", "info", "warn", "error" |
socket_path | path | auto | Unix-domain socket for dormantctl IPC (defaults to XDG_RUNTIME_DIR) |
idle_time_unit | string | "auto" | How to interpret screensaver idle values: "auto", "ms", "s". KDE returns ms despite the spec saying seconds — auto detects the unit at runtime |
idle_source | string | "auto" | User-activity source: "auto", "wayland", "dbus", or "macos" (CoreGraphics idle time, macOS builds only) |
macos_idle_frozen_polls | integer | 3 | macOS only: consecutive identical-looking idle-clock polls before a reading is treated as frozen (defensive against a stuck clock) |
macos_idle_sanity_cap | duration | "24h" | macOS only: idle readings above this are treated as bogus, not real idle time (must be > 0) |
macos_idle_startup_grace | duration | "15s" | macOS only: idle readings are untrusted for this long after daemon startup (must be > 0) |
reload_debounce | duration | "500ms" | Coalesces rapid config-file changes into a single reload |
web_port | integer | unset | Web UI port; unset disables the server even in a web-ui build |
web_bind | IP address | "127.0.0.1" | Web UI bind address |
web_allow_nonloopback | boolean | false | Required before web_bind may use a non-loopback address |
entity_crud_enabled | boolean | true | Allow web creation/deletion of sensors, zones, displays, and rules |
pairing_enabled | boolean | true | Allow the web Samsung pairing wizard |
pair_timeout | duration | "120s" | Pairing timeout; valid range 30s–300s |
[sensors.<id>] — sensor definitions
Each sensor has a user-chosen id (e.g., desk). The type field selects the source.
Common fields on all sensor types:
| Key | Type | Default | Description |
|---|---|---|---|
kind | string | "presence" | Sensor semantics: "presence" (binary occupied/vacant) or "motion" (transient event) |
hold_time | duration | none | Per-sensor override: how long occupancy persists after the last trigger |
stale_timeout | duration | none | Per-sensor override: how long before no data means unavailable |
type = "mqtt"
Connects to an MQTT broker and subscribes to a topic.
| Key | Type | Required | Description |
|---|---|---|---|
broker_url | string | yes | MQTT broker URL (e.g., tcp://localhost:1883) |
topic | string | yes | MQTT topic to subscribe to |
field | string | "/occupancy" | JSON pointer into the payload (RFC 6901) |
payload_on | string | none | Override for the "on" payload value (default: JSON true) |
payload_off | string | none | Override for the "off" payload value (default: JSON false) |
availability_topic | string | <topic>/availability | Override the derived availability/LWT topic |
availability_payload_online | string | "online" | Payload meaning the device is reachable |
availability_payload_offline | string | "offline" | Payload meaning the device is unreachable |
If your broker requires authentication, add a [mqtt] section to your credentials file:
# credentials.toml
[mqtt."tcp://mqtt.local:1883"]
username = "your-username"
password = "your-password"
The credentials key must match the sensor's broker_url exactly. A mqtt:// / tcp:// mismatch or a trailing difference causes an anonymous connection attempt, which an authenticated broker rejects.
Retained state values are accepted on initial subscribe and reconnect. See
Retained values and availability
before setting zones.<id>.unavailable_policy = "absent" on an MQTT-backed
zone.
Example:
[sensors.desk]
type = "mqtt"
broker_url = "tcp://mqtt.local:1883"
topic = "zigbee2mqtt/desk-sensor"
type = "ha"
Connects to Home Assistant via WebSocket and subscribes to an entity state.
| Key | Type | Required | Description |
|---|---|---|---|
url | string | yes | HA WebSocket URL (e.g., ws://ha.local:8123/api/websocket) |
entity | string | yes | Entity ID to track (e.g., binary_sensor.couch_presence) |
The HA long-lived access token goes in the credentials file, not in the main config:
# credentials.toml
ha_token = "eyJ..."
type = "usb-ld2410"
Reads presence data from an HLK-LD2410 mmWave radar module over USB-serial.
| Key | Type | Required | Description |
|---|---|---|---|
port | string | yes | Serial port path (e.g., /dev/ttyUSB0) |
baud | integer | 256000 | Baud rate |
Example:
[sensors.radar]
type = "usb-ld2410"
port = "/dev/ttyUSB0"
[zones.<id>] — zone definitions
A zone fuses one or more sensor references into a single presence signal. Members can be sensor IDs or nested zones ("zone:<id>").
| Key | Type | Required | Description |
|---|---|---|---|
mode | string | yes | Fusion mode: "any", "all", "quorum", "weighted" |
members | []string | yes | Sensor/zone IDs. Prefix "zone:" for nested zones |
quorum | integer | conditional | Required member count for "quorum" mode |
threshold | float | conditional | Threshold fraction (0.0–1.0) for "weighted" mode |
weights | table | optional | Per-member float weights for "weighted" mode |
unavailable_policy | string | "present" | How to treat unavailable members: "present" (fail-safe) or "absent" |
Zone modes
any— occupied if any member reports occupied.all— occupied only if every member reports occupied.quorum— occupied if at least N members report occupied (requiresquorumkey).weighted— occupied if the weighted sum of occupied members meets a threshold (requiresthresholdandweightskeys).
Unavailable policy
When a sensor stops reporting (broker down, USB unplugged), it becomes unavailable. The zone's unavailable_policy decides what to do:
present(default, fail-safe) — treat unavailable sensors as occupied. The room is never blanked unless all sensors confirm vacancy.absent— treat unavailable sensors as vacant. Only use this when you are certain a sensor failure is acceptable — a sensor that goes offline will trigger a blank.
[displays.<id>] — display definitions
Each display has a user-chosen id. The controllers list is an ordered fallback chain.
| Key | Type | Required | Description |
|---|---|---|---|
controllers | []string | yes | Ordered list of controller names to try |
blank_mode | string | yes | Primary blank mode: "screen_off_audio_on", "power_off", "brightness_zero" |
degraded_mode | string | no | Fallback mode if primary is unsupported |
output | string | conditional | Output selector: KWin output name (e.g., "DP-1", Linux kwin-dpms); "cg:<uuid>" CoreGraphics display UUID (macOS macos-gamma-black, required — see Displays); absent or "all" for macos-display-sleep (no per-display selector) |
ddc_display | string | conditional | DDC/CI display identifier |
host | string | conditional | Hostname/IP for network-controllable displays |
wol_mac | string | conditional | MAC address for Wake-on-LAN |
blank_command | string | conditional | Shell command to blank (for "command" controller) |
wake_command | string | conditional | Shell command to wake (for "command" controller) |
modes | []string | conditional | Supported modes for "command" or "ha-passthrough" |
ha_url | string | conditional | HA URL for "ha-passthrough" |
blank_service | string | conditional | HA service to call for blanking |
blank_data | any | conditional | HA service data for blanking (TOML value) |
wake_service | string | conditional | HA service to call for waking |
wake_data | any | conditional | HA service data for waking (TOML value) |
command_timeout | duration | "10s" | Timeout for a single blank/wake command |
restore_brightness | integer | 80 | DDC/CI brightness restored on wake (1–100) |
samsung_restore_backlight | integer | 50 | Samsung IP Control G2 backlight restored when no saved value exists (1–50) |
treat_unreachable_as_blanked | boolean | true | If controller is unreachable, assume display is blanked (fail-safe) |
panel_type | string | "unknown" | Panel classification recorded by panel-wear tracking: "woled", "qd-oled", or "unknown" |
Manual-only displays
A display in [displays] referenced by no rule is manual-only: the
daemon builds it and it responds to dormantctl blank / dormantctl wake, the
web UI, and the tray app, but no zone or rule ever auto-blanks or auto-wakes it.
A ladder on a rule-less display is rejected at validation time with error
E_CONFIG_INVALID — a ladder is an auto-escalation that requires a rule to
drive it. Use blank_mode (or blank_mode + degraded_mode) for
manual-only displays.
Manual-only phase survives a config reload: if you blanked a manual-only
display via dormantctl blank and then edit the config, it stays blanked
(not defensive-woken). Across a full daemon restart (not reload) there
is no persisted state, so a manual-only display starts active.
dormantctl blank — soft and hard modes (issue #124)
dormantctl blank <display> has two modes. The default is soft: the
daemon walks the display's configured render/stage/controller blank ladder
from its first stage and never hard-powers the panel. Pass --hard to
issue the operator-override PowerOff (hard mode), the same path the
tray's "Force blank" button and the web UI's "Force blank" button take.
# Safe: walks the ladder from its first stage. No prompt.
dormantctl blank main
# Operator override: prompts on stdin for 'y' or 'Y' before issuing
# the primary PowerOff. Shared panels will affect every connected
# machine. Refuses to run when stdin is not a TTY.
dormantctl blank main --hard
# Bypass the prompt for CI / scripts.
dormantctl blank main --hard --yes
# `--yes` without `--hard` is rejected by clap; the safe-soft path is
# always the default and never prompts.
The two modes are deliberately split at the IPC boundary (issue #124 — a
forced PowerOff on a shared panel can hard-power the USB hub and starve a
downstream sensor). Legacy dormantctl builds that predate the split
send {"req":"blank","display":"x"} with no mode field, which the daemon
defaults to soft (safety-first). The tray and web UI always send hard
explicitly (with a confirm dialog) and are unaffected by the new default.
Example — a Samsung Tizen TV controlled by hand:
[displays.tv]
controllers = ["samsung-tizen"]
blank_mode = "screen_off_audio_on"
host = "192.168.1.50"
Escalation ladder
Instead of blank_mode and degraded_mode, a display can define an ordered
ladder of stages. Each stage is tried in order; if one fails, the next one
runs. A stage with a dwell duration ("dwell stage") is held for that long
before the ladder advances. The last stage (the terminal stage) has no time
limit — the ladder stays there until an external event (sensor, user wake)
moves it.
blank_mode and ladder are mutually exclusive — a config containing both
is rejected at startup.
Ladder form (array of tables)
[displays.main]
controllers = ["ddcci", "kwin-dpms"]
output = "DP-1"
ladder = [
{ kind = "render_black", dwell = "30s" }, # audio-safe black overlay
{ kind = "power_off" }, # terminal: true panel-off
]
Stage kinds
| Kind | Description |
|---|---|
power_off | Controller power-off mode |
screen_off_audio_on | Controller screen-off-audio-on mode |
brightness_zero | Controller brightness-zero mode |
render_black | Software fullscreen black overlay (render backend) |
render_screensaver | Software screensaver (render backend; requires screensaver config) |
dwell
An optional humantime duration
(e.g., "30s", "5m") the ladder stays at this stage before advancing.
Omitting dwell (or setting it to None) makes the stage terminal — the
ladder stops here and waits for an external event.
Every non-terminal stage must have a dwell; validation rejects ladders
that would skip past a dwell-less intermediate stage.
Render stages
render_black and render_screensaver use the software render backend, which
is off by default. Build dormant with --features render to enable it;
configs containing a render stage are rejected at startup with error
E_RENDER_UNAVAILABLE when the feature is absent.
Render stages require:
outputset on the display (the Wayland output connector name, e.g."DP-1").- At least one local controller (
kwin-dpms,ddcci, orcommand) in the display'scontrollerslist — render stages cannot run on a remote-only display (samsung-tizen/ha-passthroughalone).
render_screensaver additionally requires a [displays.<id>.screensaver]
section with at least one image source (a path or urls).
Screensaver configuration
[displays.my-display.screensaver]
trigger = "vacancy" # only value supported
audio = false # default: false (muted)
[[displays.my-display.screensaver.source]]
path = "/home/user/Pictures/screensaver"
recurse = false # default: false
shuffle = true # mutually exclusive with `order`
image_duration = "8s" # per-item duration override
[[displays.my-display.screensaver.source]]
urls = ["https://example.com/background.jpg"]
order = "sequential" # or "wear-even"; mutually exclusive with `shuffle`
wear_tag = "landscape" # optional source-level wear label
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
trigger | string | no | "vacancy" | Trigger for the screensaver. Only "vacancy" is supported (the ladder itself is vacancy-driven). |
audio | boolean | no | false | Whether audio playback is enabled. false mutes the player at init. |
scale_mode | string | no | "fill" | How source frames are scaled onto the output rectangle. One of "fill", "fit", "stretch", "center". See Scale mode below. |
transition | string | no | "crossfade" | How consecutive playlist items transition. "crossfade" blends successive frames with a per-pixel u8 lerp; "none" cuts immediately (the pre-feature behaviour). |
transition_duration | duration | no | "1s" | Length of the crossfade blend (ignored when transition = "none"). Bounded to 100ms..=10s. |
shift_px | integer | no | 2 | Pixel-shift distance on the screensaver surface. 0 disables pixel shift. |
shift_interval | duration | no | "120s" | Time between screensaver shifts; minimum 10s. |
wear_temperature | number | no | 0.05 | Temperature for wear-even scoring, range 0.0..=1.0. |
shift_heat_bias | number | no | 0.25 | Bias toward cooler regions for existing pixel-shift offsets, range 0.0..=1.0. |
[[…source]] | array | yes | — | Ordered list of media sources for the playlist. |
Each source supports:
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | conditional | — | Local directory of images / video files. Mutually exclusive with urls. |
urls | []string | conditional | [] | Remote URLs. Mutually exclusive with path. |
recurse | boolean | no | false | Scan path recursively for media files. |
shuffle | boolean | no | false | Shuffle items from this source (Fisher-Yates, seeded per restart). Mutually exclusive with order. |
order | string | no | — | Ordering strategy: "sequential" or "wear-even". Mutually exclusive with shuffle; missing luma grids fail open to the configured order. |
wear_tag | string | no | — | Source-level label copied to each item for wear attribution. |
image_duration | duration | no | "8s" | Per-image display duration override (must be > 0). |
Pixel shift applies only to render_screensaver. The render_black surface
is a uniform black field and never shifts. Defaults are 2 px every 2 minutes;
set displays.<id>.screensaver.shift_px = 0 to disable it.
Transitions
When transition = "crossfade" (the default), successive playlist items
blend with a per-pixel u8 lerp over transition_duration. The blend is
driven by a calloop timer on the Wayland thread; measured blend cost
is ≈0.9 ms/frame at 3072×1728 — negligible against any reasonable
frame budget. Set transition_duration between 100ms and 10s;
the validator rejects anything outside that range.
transition = "none" keeps the legacy hard-cut behaviour (byte-identical
to pre-M3) and is useful for benchmarks or operators who prefer the
instantaneous switch. When transition = "none", the transition_duration
field is ignored.
Playlist assembly: The playlist is built at startup and on every config
reload — file-system scanning runs off the Wayland thread. Changes to the
source directories or screensaver config require a reload (SIGHUP or
dormantctl reload) to take effect.
Feature gate: render_screensaver requires the render build feature.
To use the screensaver, the display's ladder must include a render_screensaver
stage, typically as the terminal stage after controller attempts have failed:
[displays.my-display]
controllers = ["ddcci", "command"]
output = "DP-1"
ladder = [
{ kind = "power_off", dwell = "30s" },
{ kind = "render_black", dwell = "5m" },
{ kind = "render_screensaver" },
]
Scale mode
The scale_mode key controls how source frames are mapped onto the rendered
output rectangle. Four modes are recognised; the default is fill, matching
the OS-screensaver norm (no black bars, regardless of source aspect ratio).
| Mode | What you see | mpv mapping |
|---|---|---|
"fill" (default) | Crop-to-fill: the source is zoomed so it covers the entire output rectangle; off-axis is cropped. No black bars. | panscan=1.0, keepaspect=yes |
"fit" | Aspect-fit letterbox: the source is scaled to fit inside the output rectangle while preserving its aspect ratio; black bars fill the gap. This was the legacy behaviour before scale_mode was added. | keepaspect=yes, panscan=0.0 |
"stretch" | Stretch: the source is scaled to exactly fill the output rectangle, distorting aspect ratio. No black bars, but proportions may look wrong; useful only when source aspect matches the display. | keepaspect=no |
"center" | 1:1 centre: the source is shown at native pixel dimensions (no scaling), centred in the output rectangle. Black bars fill the gap. | video-unscaled=yes, keepaspect=yes |
Validation rejects any unknown value with an E_SCREENSAVER_SOURCE-class
error naming the allowed set. The four modes were empirically verified to
take effect under MPV_RENDER_API_TYPE_SW (the libmpv SW render context
used by the screensaver) — the property values flow through to mpv and
influence the scaling at frame-blit time.
Validation rejects:
- A
render_screensaverstage without ascreensaversection. - A
screensaversection with no sources, or sources with neitherpathnorurls. - A source with both
pathandurlsset. - A source with both
shuffleandorderset. - An
ordervalue other than"sequential"or"wear-even". - A
triggervalue other than"vacancy". - A
scale_modevalue other than"fill","fit","stretch", or"center". - An
image_durationof zero.
Backward compatibility
Configs that use blank_mode (and optionally degraded_mode) — the pre-ladder
style — continue to work unchanged. Internally, blank_mode is desugared to a
single-stage ladder ({ kind = "<blank_mode>" }) with no dwell.
blank_mode + ladder together is rejected. degraded_mode + ladder is
also rejected (the ladder itself chains fallbacks).
[rules.<id>] — rule definitions
A rule links a zone to one or more displays with timing parameters.
| Key | Type | Required | Description |
|---|---|---|---|
zone | string | yes | Zone ID whose state drives this rule |
displays | []string | yes | Display IDs to control |
grace_period | duration | "60s" | Zone must be stable for this long before acting |
min_blank_time | duration | "10s" | Minimum time a display stays blanked before waking |
min_wake_time | duration | "10s" | Minimum time a display stays awake before blanking |
inhibitors | []string | [] | Named inhibitors: "user-activity", "manual-pause", "audio-playback", "call" |
activity_idle_threshold | duration | "2m" | How long without input before user-activity inhibitor considers user idle |
activity_poll_interval | duration | "5s" | How often to poll activity state |
wake_retries | integer | 3 | Number of wake retries before escalating |
wake_retry_backoff | duration | "2s" | Backoff before the first wake retry |
wake_retry_interval | duration | "60s" | Interval between successive wake retries |
input_wake_hold | duration | "120s" | How long to hold a display awake after an input-wake in a vacant room; "0s" disables |
"manual-pause" is accepted in this list but is a deliberate no-op: it is
never mapped to an inhibitor check, so listing it changes nothing. Manual
pause is a real, separate mechanism — dormantctl pause/resume, the web
UI's pause/resume action, or POST/GET /api/pause//api/resume — which
sets the display's Overlays.paused state directly and freezes the blank
path independently of inhibitors.
"user-activity" reads raw input idle. On Wayland compositors that offer
ext_idle_notifier_v1 version 2, dormant uses the input-idle notification,
which ignores application idle inhibitors — a browser tab holding a
WebRTC or video inhibitor cannot hold a blank on a vacant room. On v1-only
compositors the legacy notification is used instead, and any application
inhibitor blocks the idle signal; if displays never blank there, check
inhibitors with your compositor's tooling and clear the offending app. Media
that should hold a blank belongs to the "audio-playback"/"call" kinds
below, not to compositor inhibitors.
"audio-playback" and "call" are backed by the PipeWire poller described
in the [audio] section below; a rule only reacts to a kind it lists here.
Recommendation: one rule per display when using inhibitors. Two rules that both target the same display each track and publish their OWN inhibitor state for it, and whichever rule's update lands second silently overwrites the first's effective value at the display level — a pre-existing limitation (per-display combination across rules does not exist), not fixed by this feature. This feature raises the odds of hitting it, since inhibitors are its whole point and a movie rule and a work rule sharing one TV is a realistic config. Give each inhibitor-using display exactly one rule.
input_wake_hold — hold input-woken displays awake
When a render-surface overlay blanks a display in a vacant room, keyboard or mouse input wakes the display so the operator can work. Without a hold, the display immediately re-enters the normal grace-then-blank countdown: every keystroke restarts the cycle, and the display blanks again after the grace period elapses — a blank → type → wake → re-blank loop (issue #125).
input_wake_hold overrides this: after a render-surface input wake, the
display is held awake for the configured duration regardless of zone state.
When the hold expires the normal grace path runs from scratch — the hold
never triggers a direct blank. Set to "0s" to disable the hold and
restore the immediate-grace behaviour. Presence returning during the hold
clears it so the display stays awake normally as long as the room is
occupied.
[wear] — panel-wear tracking
Panel-wear tracking is enabled by default. It records brightness-weighted on-hours and exposes them through the web UI; it does not change blank/wake timing.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable the tracker and ledger I/O |
sample_interval | duration | "60s" | Panel-state sampling interval |
persist_interval | duration | "5m" | Ledger write interval |
read_timeout | duration | "2s" | Budget for one panel read |
grid_rows | integer | 9 | Logical ledger rows; v1 attribution is uniform |
grid_cols | integer | 16 | Logical ledger columns; v1 attribution is uniform |
fallback_brightness | float | 0.5 | Brightness fraction used when readback fails |
screensaver_factor | float | 0.35 | Fixed attribution factor during render_screensaver |
short_cycle_dwell | duration | "10m" | Blanked dwell that counts as a long rest window |
advisory_after | duration | "96h" | Time without a long rest window before the advisory appears |
See Panel-wear tracking for ledger location and limits.
[notifications] — failure notifications
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable desktop notifications over the session D-Bus |
wake_attempt_threshold | integer | 3 | Consecutive wake failures before notification; minimum 1 |
cooldown | duration | "15m" | Minimum interval between repeat notices per display; minimum 1m |
notify_recovery | boolean | true | Notify when a previously failing display succeeds |
This section gates desktop notifications only. The tray failure state and web failure banner remain active. See Failure notifications.
[watchdog] — watchdog + last-known-good rollback
| Key | Type | Default | Description |
|---|---|---|---|
lkg_enabled | boolean | true | Save a last-known-good config after a healthy stability window |
lkg_rollback_enabled | boolean | true | Allow counted crash-loop rollback |
stability_window | duration | "5m" | Healthy runtime before LKG promotion; minimum 30s |
The systemd watchdog interval comes from the unit, not this table. See Watchdog + last-known-good rollback.
[coordination] — multi-machine shared-display coordination
Self-activating: polls ownership only when at least one display has
scope = "shared". There is no enabled key — switching is a direct local
DDC write with no network protocol, pairing, or claim transport.
| Key | Type | Default | Description |
|---|---|---|---|
poll_interval | duration | "2s" | Shared-display ownership polling cadence; minimum "1s". |
state_poll_interval | duration | unset | Panel-state (brightness/power) refresh cadence for DisplaySnapshot cosmetics. When unset, defaults to max(30s, poll_interval); when set, must be >= poll_interval. |
loss_confirmations | integer | 3 | Consecutive agreeing VCP 0x60 readings required before a poll-observed verdict flips — symmetric, so it debounces both loss (true → false) and gain (false → true); a verified local pull commits ownership immediately and does not wait. Range 1..=10. Defends against garbled cross-machine DDC reads — see Multi-machine KVM switching. Latency: with the default poll_interval = 2s, a genuine input switch takes ~6 s to commit; during that window the old owner may still blank while the new owner eagerly wakes. Limits: the debounce reduces but does not eliminate false losses — if collisions return the same wrong code N times in a row, a false loss can still commit; N=3 is ~3× less likely than N=1, not zero. Setting loss_confirmations = 1 commits loss on the next reading but is flap-susceptible. |
activity_follow | boolean | false | When true, a genuine local activity edge pulls a shared display to this machine. |
arm_after | duration | "7s" | Grace window after a local arm before the pull commits. |
cooldown | duration | "3s" | Minimum interval between successive activity-driven pulls; hotkey/CLI/web bypass this. |
See Multi-machine KVM switching for setup and hook semantics.
[audio] — PipeWire audio- and call-aware blanking
Global (not per-rule) settings for the pw-dump-polling audio inhibitor.
Stream classification is a system-wide fact about one PipeWire instance, so
this section is global; rules opt into it by listing "audio-playback"
and/or "call" in their own inhibitors. The poller polls
pw_dump_command on an interval, classifies the running PipeWire graph, and
asserts/deasserts each kind independently — the screen stays awake while
ANY declared kind (user activity, audio playback, or a call) is active.
Corked, paused, idle, and suspended streams never inhibit — only a
"running" stream does. Deassertion is immediate on the poll after activity
stops; assertion (other than at daemon/generation startup, where an
already-running stream is trusted immediately) waits for min_active of
continuous activity, so a short notification chime does not hold a display
awake.
Failure modes (missing pw-dump binary, a timeout, malformed output, or the
poller's internal circuit breaker tripping after repeated unreapable
subprocesses) always fail toward blanking: both kinds are published
false, never toward holding the screen on indefinitely. A single failed
poll is tolerated without changing state (jitter allowance); two
consecutive failures deassert both kinds.
| Key | Type | Default | Description |
|---|---|---|---|
poll_interval | duration | "5s" | How often to invoke pw_dump_command; minimum 1s |
min_active | duration | "3s" | Continuous stream activity required before asserting (deassertion is immediate); must not exceed 10 × poll_interval |
call_roles | []string | ["Communication"] | media.role values that mean "this running stream is a call" |
playback_roles | []string | unset | Optional narrowing filter for "audio-playback": unset means every non-call running output stream inhibits; when set, only listed roles do. An explicitly empty list ([]) is rejected — it would silently disable playback inhibition |
capture_is_call | boolean | false | Whether a running INPUT stream (an open microphone) counts as a call — see the warning below |
pw_dump_command | string | "pw-dump" | Override invocation, split on whitespace with no shell and no quoting (paths with spaces are unsupported); primarily the test/fake-script seam |
Warning:
capture_is_callfalse positives. PipeWire input nodes commonly sit in the"running"state for hours under ordinary setups — an idling Discord/Teams call, an OBS microphone source, a wake-word assistant, or a browser tab holding a granted mic permission. Enablingcapture_is_calltreats all of these as an active call and can hold a display awake indefinitely. It defaults tofalse(call detection is role-based viacall_rolesout of the box); enable it only if you understand your system's microphone-node behavior.
The poller never touches the wake path (wake is never gated by any inhibitor) and its task lives and dies with its config generation, exactly like the user-activity inhibitor.
[credentials] — credentials file
Stored in a separate file (credentials.toml) with 600 permissions.
| Key | Type | Description |
|---|---|---|
ha_token | string | Home Assistant long-lived access token |
samsung.<host> | string | Samsung TV token, one per host key |
Example:
ha_token = "eyJ..."
[samsung]
"192.168.1.50" = "eyJ..."
Config editor (web UI)
When the web UI is enabled (see Web UI), the Settings tab on the Config page provides a form-based editor for live config changes without editing the TOML file directly.
Editable fields: leaf values, whole arrays, and a limited set of optional
keys. The form can create and delete sensors, zones, displays, and rules when
daemon.entity_crud_enabled = true (the default). Existing entity type
values, blank_data, and wake_data are locked. Display command strings stay
file-only; the browser creation path never accepts daemon-executed shell
commands.
Backups: every apply creates a timestamped backup of the previous config in <config-dir>/backups/config.toml.<rfc3339>.<rand>, keeping at most 5 newest copies. The directory is created with mode 0o700.
See the Web UI page for the full editor workflow, outcome banners, conflict handling, and unsaved-changes guard.
Cookbook: multi-zone household
Desk monitor blanks when you leave the room. Living room TV blanks when no motion for 5 minutes. Kitchen display stays awake while any movement in the open-plan area. Hallway light-weight sensor provides backup.
config_version = 1
# ── Sensors ──
[sensors.desk_radar]
type = "usb-ld2410"
port = "/dev/ttyUSB0"
[sensors.living_motion]
type = "mqtt"
broker_url = "tcp://mqtt.local:1883"
topic = "zigbee2mqtt/living-room-sensor"
hold_time = "5m"
kind = "motion"
[sensors.kitchen_presence]
type = "mqtt"
broker_url = "tcp://mqtt.local:1883"
topic = "zigbee2mqtt/kitchen-sensor"
kind = "presence"
[sensors.hallway]
type = "ha"
url = "ws://ha.local:8123/api/websocket"
entity = "binary_sensor.hallway_occupancy"
# ── Zones ──
[zones.desk]
mode = "any"
members = ["desk_radar"]
[zones.living_room]
mode = "all"
members = ["living_motion"]
[zones.open_plan]
mode = "any"
# Treat hallway as backup — if kitchen sensor goes stale, still don't blank
members = ["kitchen_presence", "hallway"]
# ── Displays ──
[displays.desk_monitor]
controllers = ["ddcci"]
blank_mode = "power_off"
[displays.living_tv]
controllers = ["samsung-tizen"]
blank_mode = "screen_off_audio_on"
host = "192.168.1.50"
wol_mac = "00:11:22:33:44:55"
[displays.kitchen_display]
controllers = ["command"]
blank_mode = "power_off"
blank_command = "/usr/bin/xset dpms force off"
wake_command = "/usr/bin/xset dpms force on"
modes = ["power_off"]
# ── Rules ──
[rules.desk_blank]
zone = "desk"
displays = ["desk_monitor"]
grace_period = "30s"
inhibitors = ["user-activity", "manual-pause", "audio-playback", "call"]
[rules.living_blank]
zone = "living_room"
displays = ["living_tv"]
grace_period = "5m"
min_blank_time = "60s"
[rules.kitchen_blank]
zone = "open_plan"
displays = ["kitchen_display"]
grace_period = "2m"