Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

KeyTypeDefaultDescription
startup_holdoffduration"30s"Wait after startup before acting (allows sensors to stabilize)
stale_sensor_timeoutduration"5m"How long a sensor can go silent before considered stale
log_levelstring"info"Tracing log level: "trace", "debug", "info", "warn", "error"
socket_pathpathautoUnix-domain socket for dormantctl IPC (defaults to XDG_RUNTIME_DIR)
idle_time_unitstring"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_sourcestring"auto"User-activity source: "auto", "wayland", "dbus", or "macos" (CoreGraphics idle time, macOS builds only)
macos_idle_frozen_pollsinteger3macOS only: consecutive identical-looking idle-clock polls before a reading is treated as frozen (defensive against a stuck clock)
macos_idle_sanity_capduration"24h"macOS only: idle readings above this are treated as bogus, not real idle time (must be > 0)
macos_idle_startup_graceduration"15s"macOS only: idle readings are untrusted for this long after daemon startup (must be > 0)
reload_debounceduration"500ms"Coalesces rapid config-file changes into a single reload
web_portintegerunsetWeb UI port; unset disables the server even in a web-ui build
web_bindIP address"127.0.0.1"Web UI bind address
web_allow_nonloopbackbooleanfalseRequired before web_bind may use a non-loopback address
entity_crud_enabledbooleantrueAllow web creation/deletion of sensors, zones, displays, and rules
pairing_enabledbooleantrueAllow the web Samsung pairing wizard
pair_timeoutduration"120s"Pairing timeout; valid range 30s300s

[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:

KeyTypeDefaultDescription
kindstring"presence"Sensor semantics: "presence" (binary occupied/vacant) or "motion" (transient event)
hold_timedurationnonePer-sensor override: how long occupancy persists after the last trigger
stale_timeoutdurationnonePer-sensor override: how long before no data means unavailable

type = "mqtt"

Connects to an MQTT broker and subscribes to a topic.

KeyTypeRequiredDescription
broker_urlstringyesMQTT broker URL (e.g., tcp://localhost:1883)
topicstringyesMQTT topic to subscribe to
fieldstring"/occupancy"JSON pointer into the payload (RFC 6901)
payload_onstringnoneOverride for the "on" payload value (default: JSON true)
payload_offstringnoneOverride for the "off" payload value (default: JSON false)
availability_topicstring<topic>/availabilityOverride the derived availability/LWT topic
availability_payload_onlinestring"online"Payload meaning the device is reachable
availability_payload_offlinestring"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.

KeyTypeRequiredDescription
urlstringyesHA WebSocket URL (e.g., ws://ha.local:8123/api/websocket)
entitystringyesEntity 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.

KeyTypeRequiredDescription
portstringyesSerial port path (e.g., /dev/ttyUSB0)
baudinteger256000Baud 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>").

KeyTypeRequiredDescription
modestringyesFusion mode: "any", "all", "quorum", "weighted"
members[]stringyesSensor/zone IDs. Prefix "zone:" for nested zones
quorumintegerconditionalRequired member count for "quorum" mode
thresholdfloatconditionalThreshold fraction (0.0–1.0) for "weighted" mode
weightstableoptionalPer-member float weights for "weighted" mode
unavailable_policystring"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 (requires quorum key).
  • weighted — occupied if the weighted sum of occupied members meets a threshold (requires threshold and weights keys).

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.

KeyTypeRequiredDescription
controllers[]stringyesOrdered list of controller names to try
blank_modestringyesPrimary blank mode: "screen_off_audio_on", "power_off", "brightness_zero"
degraded_modestringnoFallback mode if primary is unsupported
outputstringconditionalOutput 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_displaystringconditionalDDC/CI display identifier
hoststringconditionalHostname/IP for network-controllable displays
wol_macstringconditionalMAC address for Wake-on-LAN
blank_commandstringconditionalShell command to blank (for "command" controller)
wake_commandstringconditionalShell command to wake (for "command" controller)
modes[]stringconditionalSupported modes for "command" or "ha-passthrough"
ha_urlstringconditionalHA URL for "ha-passthrough"
blank_servicestringconditionalHA service to call for blanking
blank_dataanyconditionalHA service data for blanking (TOML value)
wake_servicestringconditionalHA service to call for waking
wake_dataanyconditionalHA service data for waking (TOML value)
command_timeoutduration"10s"Timeout for a single blank/wake command
restore_brightnessinteger80DDC/CI brightness restored on wake (1–100)
samsung_restore_backlightinteger50Samsung IP Control G2 backlight restored when no saved value exists (1–50)
treat_unreachable_as_blankedbooleantrueIf controller is unreachable, assume display is blanked (fail-safe)
panel_typestring"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

KindDescription
power_offController power-off mode
screen_off_audio_onController screen-off-audio-on mode
brightness_zeroController brightness-zero mode
render_blackSoftware fullscreen black overlay (render backend)
render_screensaverSoftware 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:

  1. output set on the display (the Wayland output connector name, e.g. "DP-1").
  2. At least one local controller (kwin-dpms, ddcci, or command) in the display's controllers list — render stages cannot run on a remote-only display (samsung-tizen / ha-passthrough alone).

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
KeyTypeRequiredDefaultDescription
triggerstringno"vacancy"Trigger for the screensaver. Only "vacancy" is supported (the ladder itself is vacancy-driven).
audiobooleannofalseWhether audio playback is enabled. false mutes the player at init.
scale_modestringno"fill"How source frames are scaled onto the output rectangle. One of "fill", "fit", "stretch", "center". See Scale mode below.
transitionstringno"crossfade"How consecutive playlist items transition. "crossfade" blends successive frames with a per-pixel u8 lerp; "none" cuts immediately (the pre-feature behaviour).
transition_durationdurationno"1s"Length of the crossfade blend (ignored when transition = "none"). Bounded to 100ms..=10s.
shift_pxintegerno2Pixel-shift distance on the screensaver surface. 0 disables pixel shift.
shift_intervaldurationno"120s"Time between screensaver shifts; minimum 10s.
wear_temperaturenumberno0.05Temperature for wear-even scoring, range 0.0..=1.0.
shift_heat_biasnumberno0.25Bias toward cooler regions for existing pixel-shift offsets, range 0.0..=1.0.
[[…source]]arrayyesOrdered list of media sources for the playlist.

Each source supports:

KeyTypeRequiredDefaultDescription
pathstringconditionalLocal directory of images / video files. Mutually exclusive with urls.
urls[]stringconditional[]Remote URLs. Mutually exclusive with path.
recursebooleannofalseScan path recursively for media files.
shufflebooleannofalseShuffle items from this source (Fisher-Yates, seeded per restart). Mutually exclusive with order.
orderstringnoOrdering strategy: "sequential" or "wear-even". Mutually exclusive with shuffle; missing luma grids fail open to the configured order.
wear_tagstringnoSource-level label copied to each item for wear attribution.
image_durationdurationno"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).

ModeWhat you seempv 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_screensaver stage without a screensaver section.
  • A screensaver section with no sources, or sources with neither path nor urls.
  • A source with both path and urls set.
  • A source with both shuffle and order set.
  • An order value other than "sequential" or "wear-even".
  • A trigger value other than "vacancy".
  • A scale_mode value other than "fill", "fit", "stretch", or "center".
  • An image_duration of 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.

KeyTypeRequiredDescription
zonestringyesZone ID whose state drives this rule
displays[]stringyesDisplay IDs to control
grace_periodduration"60s"Zone must be stable for this long before acting
min_blank_timeduration"10s"Minimum time a display stays blanked before waking
min_wake_timeduration"10s"Minimum time a display stays awake before blanking
inhibitors[]string[]Named inhibitors: "user-activity", "manual-pause", "audio-playback", "call"
activity_idle_thresholdduration"2m"How long without input before user-activity inhibitor considers user idle
activity_poll_intervalduration"5s"How often to poll activity state
wake_retriesinteger3Number of wake retries before escalating
wake_retry_backoffduration"2s"Backoff before the first wake retry
wake_retry_intervalduration"60s"Interval between successive wake retries
input_wake_holdduration"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.

KeyTypeDefaultDescription
enabledbooleantrueEnable the tracker and ledger I/O
sample_intervalduration"60s"Panel-state sampling interval
persist_intervalduration"5m"Ledger write interval
read_timeoutduration"2s"Budget for one panel read
grid_rowsinteger9Logical ledger rows; v1 attribution is uniform
grid_colsinteger16Logical ledger columns; v1 attribution is uniform
fallback_brightnessfloat0.5Brightness fraction used when readback fails
screensaver_factorfloat0.35Fixed attribution factor during render_screensaver
short_cycle_dwellduration"10m"Blanked dwell that counts as a long rest window
advisory_afterduration"96h"Time without a long rest window before the advisory appears

See Panel-wear tracking for ledger location and limits.

[notifications] — failure notifications

KeyTypeDefaultDescription
enabledbooleantrueEnable desktop notifications over the session D-Bus
wake_attempt_thresholdinteger3Consecutive wake failures before notification; minimum 1
cooldownduration"15m"Minimum interval between repeat notices per display; minimum 1m
notify_recoverybooleantrueNotify 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

KeyTypeDefaultDescription
lkg_enabledbooleantrueSave a last-known-good config after a healthy stability window
lkg_rollback_enabledbooleantrueAllow counted crash-loop rollback
stability_windowduration"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.

KeyTypeDefaultDescription
poll_intervalduration"2s"Shared-display ownership polling cadence; minimum "1s".
state_poll_intervaldurationunsetPanel-state (brightness/power) refresh cadence for DisplaySnapshot cosmetics. When unset, defaults to max(30s, poll_interval); when set, must be >= poll_interval.
loss_confirmationsinteger3Consecutive 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_followbooleanfalseWhen true, a genuine local activity edge pulls a shared display to this machine.
arm_afterduration"7s"Grace window after a local arm before the pull commits.
cooldownduration"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.

KeyTypeDefaultDescription
poll_intervalduration"5s"How often to invoke pw_dump_command; minimum 1s
min_activeduration"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[]stringunsetOptional 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_callbooleanfalseWhether a running INPUT stream (an open microphone) counts as a call — see the warning below
pw_dump_commandstring"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_call false 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. Enabling capture_is_call treats all of these as an active call and can hold a display awake indefinitely. It defaults to false (call detection is role-based via call_roles out 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.

KeyTypeDescription
ha_tokenstringHome Assistant long-lived access token
samsung.<host>stringSamsung 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"