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

Introduction

dormant is a Rust daemon that blanks OLED PC monitors and TVs when presence sensors report an empty room, then wakes them on return. Sensors arrive through MQTT, Home Assistant WebSocket, or USB-serial mmWave radar. Displays use DDC/CI, KWin DPMS, Samsung Tizen, Home Assistant passthrough, or shell commands.

Why dormant?

OLED panels burn in. Static UI elements — taskbars, window decorations, desktop icons — degrade the panel over hundreds of hours. Dimming the display or turning it off when no one is watching extends panel life.

Built-in monitor proximity sensors protect one panel. dormant instead fuses room-level sensors across zones and controls every configured display, including TVs.

How it works at a high level

  1. Sensors report presence — a Zigbee mmWave radar over MQTT, a Home Assistant binary sensor, or a USB-LD2410 module.
  2. Zones fuse sensors with any, all, quorum, or weighted logic. A zone is "occupied" or "vacant".
  3. Rules link zones to displays with timing parameters. Grace periods and minimum blank/wake times prevent thrash; user activity and manual pause can inhibit blanking.
  4. Displays receive blank/wake commands through an ordered controller chain with fallback and retry.

What dormant protects against

OLED panels degrade with static content. The effectiveness of each blank mode varies:

ModeOLED protectionAudioWake speed
screen_off_audio_onFull (panel off, electronics on)YesFast (~1s)
power_offFull (DPMS off, DDC power off)NoSlower (monitor-dependent)
brightness_zeroPartial (pixels still powered, but minimal emission)YesInstant

Use screen_off_audio_on for TVs where the controller can turn off the picture without powering down the set. Use power_off for PC monitors. brightness_zero is a near-black dim: the panel stays energized, while the source and its audio can continue running.

Fail-safe design

Unavailable means present, never absent. If a sensor stops reporting (broker down, USB unplugged, network loss), the zone treats it as present. dormant never blanks a display when it cannot confirm the room is empty. This is the single most important safety invariant in the codebase.

Panel-wear tracking, failure notifications, and watchdog + last-known-good rollback report or recover faults around this pipeline. They do not weaken the fail-safe presence rule.

Installation

Prerequisites

  • Linux (x86_64 or aarch64) with a desktop environment (X11 or Wayland), or macOS (arm64 or x86_64) — see macOS (M1) below for the macOS-specific install path
  • Rust 1.88+ (MSRV) if installing from source
  • Build dependencies for the full daemon (Linux only — macOS needs nothing beyond Xcode Command Line Tools): sudo apt install libudev-dev libwayland-dev libmpv-dev pkg-config
  • If pkg-config cannot find libudev, set PKG_CONFIG_PATH=/usr/lib/pkgconfig

Render backend

The software render backend (render_black, render_screensaver ladder stages) is off by default. The full build below enables it. For a smaller build, omit render and its libwayland-dev / libmpv-dev dependencies.

  • Build: add --features rendercargo build --release --features render
  • Dependencies: libwayland-dev, libmpv-dev, and pkg-config
  • Without the feature, configs using render stages are rejected at startup with E_RENDER_UNAVAILABLE; the daemon, CLI, and non-render sensors/displays still build and run normally.

Homebrew

The Legion Works tap publishes one formula per binary for macOS and Linuxbrew. Formulas are versioned with each release.

brew install legion-works/tap/dormantd
brew install legion-works/tap/dormantctl
brew install legion-works/tap/dormant-tray

Arch Linux (AUR)

dormant-bin installs pre-built x86_64 release binaries and the systemd user units under /usr/lib/systemd/user/.

yay -S dormant-bin

From source

git clone https://github.com/legion-works/dormant.git
cd dormant
sudo apt install libudev-dev libwayland-dev libmpv-dev pkg-config
cargo build --release --features web-ui,render
install -Dm755 target/release/dormantd ~/.local/bin/dormantd
install -Dm755 target/release/dormantctl ~/.local/bin/dormantctl

Binaries land in ~/.local/bin/ — make sure this is on your PATH.

Tray applet

On Linux, dormant-tray is a KDE StatusNotifierItem applet. On macOS, it is a native NSStatusItem menu-bar item. Both provide status, pause/resume, and blank/wake controls through the daemon's Unix socket.

The dormant tray applet: status tooltip and pause/blank/wake menu

install -Dm755 target/release/dormant-tray ~/.local/bin/dormant-tray

See Tray autostart below to run it on every login.

From release

The cargo-dist pipeline publishes shell installers and tarballs for every binary. These URLs always resolve to the newest release; substitute download/vX.Y.Z for a specific one. Install on Linux x86_64 or aarch64:

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/legion-works/dormant/releases/latest/download/dormantd-installer.sh | sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/legion-works/dormant/releases/latest/download/dormantctl-installer.sh | sh

dormant-tray-installer.sh is also available in the same directory. Checksums are published alongside every artifact; verify with:

sha256sum -c dormantd-x86_64-unknown-linux-gnu.tar.xz.sha256
sha256sum -c dormantctl-x86_64-unknown-linux-gnu.tar.xz.sha256

Systemd user unit

dormant runs as a user service — it does not need root. The unit file (dormant.service) is in the release tarball (under dormantd-<triple>/systemd/dormant.service) and in the source tree at crates/dormantd/systemd/dormant.service.

The AUR package installs the unit under /usr/lib/systemd/user/ already. Use the manual paths below for source builds and release tarballs.

Install it:

mkdir -p ~/.config/systemd/user

# From source:
cp crates/dormantd/systemd/dormant.service ~/.config/systemd/user/

# From a release tarball:
tar -xf dormantd-x86_64-unknown-linux-gnu.tar.xz \
    dormantd-x86_64-unknown-linux-gnu/systemd/dormant.service
mv dormantd-x86_64-unknown-linux-gnu/systemd/dormant.service \
    ~/.config/systemd/user/dormant.service

# Then enable and start:
systemctl --user daemon-reload
systemctl --user enable --now dormant

Check status:

systemctl --user status dormant
journalctl --user -u dormant -f

The unit runs as Type=notify, restarts on failure, and uses a 150-second engine-liveness watchdog. Reload with systemctl --user reload dormant. To stop:

systemctl --user stop dormant

When upgrading from a unit that used Type=simple, install the new dormantd binary before copying or reloading the new unit. Then run systemctl --user daemon-reload and systemctl --user restart dormant. See Watchdog + last-known-good rollback.

Configuration file location

dormant reads config from these paths, first match wins:

  1. --config CLI flag
  2. $DORMANT_CONFIG environment variable
  3. $XDG_CONFIG_HOME/dormant/config.toml (usually ~/.config/dormant/config.toml)

MQTT credentials, HA tokens, and Samsung TV tokens go in a separate file with restricted permissions:

$XDG_CONFIG_HOME/dormant/credentials.toml

Set permissions to 600 — dormant will refuse to load a credentials file readable by others:

chmod 600 ~/.config/dormant/credentials.toml

Tray autostart

Linux (systemd)

Run dormant-tray on every graphical session with the provided user unit — the same mechanism as the daemon. The unit file (dormant-tray.service) ships in the release tarball under dormant-tray-<triple>/systemd/dormant-tray.service and in the source tree at crates/dormant-tray/systemd/dormant-tray.service.

mkdir -p ~/.config/systemd/user

# From source:
cp crates/dormant-tray/systemd/dormant-tray.service ~/.config/systemd/user/

# From a release tarball:
tar -xf dormant-tray-x86_64-unknown-linux-gnu.tar.xz \
    dormant-tray-x86_64-unknown-linux-gnu/systemd/dormant-tray.service
mv dormant-tray-x86_64-unknown-linux-gnu/systemd/dormant-tray.service \
    ~/.config/systemd/user/dormant-tray.service

# Then enable and start:
systemctl --user daemon-reload
systemctl --user enable --now dormant-tray

The unit uses ExecStart=%h/.local/bin/dormant-tray, so systemd expands the path from your home directory at launch — no reliance on PATH. It starts after dormant.service and restarts on failure. A plain XDG .desktop autostart does not work here: the systemd autostart generator resolves a relative Exec= against a minimal boot PATH that excludes ~/.local/bin, so no unit gets generated.

macOS (launchd)

dormantctl launchd install writes both checked-in LaunchAgent plists to ~/Library/LaunchAgents/: com.legionworks.dormant.plist for the daemon and com.legionworks.dormant-tray.plist for the tray. It does not bootstrap or start either job. Start them immediately, without waiting for the next login:

dormantctl launchd install
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.legionworks.dormant.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.legionworks.dormant-tray.plist

The tray agent runs only in an Aqua session. It starts at login, restarts after an unsuccessful exit, and throttles restarts to one per 10 seconds. Its stdout and stderr logs are ~/Library/Logs/dormant/dormant-tray.log and ~/Library/Logs/dormant/dormant-tray.err.log.

To remove the managed plist files, first boot out any loaded jobs, then run:

launchctl bootout gui/$(id -u)/com.legionworks.dormant
launchctl bootout gui/$(id -u)/com.legionworks.dormant-tray
dormantctl launchd uninstall

dormantctl launchd uninstall removes the two files only. It does not boot out loaded jobs, so launchd retains their last-loaded definitions until they are explicitly booted out.

macOS (M1)

dormant runs natively on macOS (arm64 and x86_64), no root required. This milestone (M1) ships:

  • DDC/CI display control (dormantctl doctor ddcci)
  • The macos-gamma-black and macos-display-sleep blank controllers
  • A CoreGraphics-based idle source
  • Read-only diagnostics: dormantctl doctor macos-idle, macos-display-sleep, macos-power

dormant-tray runs as a native NSStatusItem menu-bar item on macOS. See Tray autostart: macOS (launchd) to install and start its LaunchAgent with the daemon.

From release (macOS)

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/legion-works/dormant/releases/latest/download/dormantd-installer.sh | sh
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/legion-works/dormant/releases/latest/download/dormantctl-installer.sh | sh

Binaries land in ~/.local/bin/, same as Linux. The installer also prints the next step — it deliberately does not run dormantctl launchd install or launchctl bootstrap for you (that would start a background daemon before you have configured it).

LaunchAgent (macOS)

dormant ships per-user LaunchAgents — the macOS analog of the systemd user units above — as checked-in daemon and tray plists: crates/dormantd/share/com.legionworks.dormant.plist and crates/dormant-tray/share/com.legionworks.dormant-tray.plist. dormantctl launchd install embeds byte-identical copies at build time.

Install it (idempotent, non-root — writes only under your home directory):

dormantctl launchd install

This atomically copies both plists to their canonical paths under ~/Library/LaunchAgents/, mode 0644. It does not start either agent — bootstrap them explicitly:

launchctl bootstrap gui/$UID "$HOME/Library/LaunchAgents/com.legionworks.dormant.plist"
launchctl bootstrap gui/$UID "$HOME/Library/LaunchAgents/com.legionworks.dormant-tray.plist"

Check status and force an immediate (re)start:

launchctl print gui/$UID/com.legionworks.dormant
launchctl kickstart -k gui/$UID/com.legionworks.dormant

Reload config the same way as Linux — signal, not restart:

launchctl kill HUP gui/$UID/com.legionworks.dormant

Daemon logs land at ~/Library/Logs/dormant/dormantd.log (stdout) and dormantd.err.log (stderr). Tray logs are ~/Library/Logs/dormant/dormant-tray.log (stdout) and dormant-tray.err.log (stderr). Each LaunchAgent's ProgramArguments creates that directory and redirects into it before exec-ing the binary, so the shell never lingers as a supervisor.

To stop and remove:

launchctl bootout gui/$(id -u)/com.legionworks.dormant
launchctl bootout gui/$(id -u)/com.legionworks.dormant-tray
dormantctl launchd uninstall

launchd uninstall only removes the two canonical files. It does not boot out a still-loaded label, so run bootout first or launchd will keep the last-loaded definition in memory after the on-disk files are gone.

Lifecycle semantics (see the plist's own comments for the full rationale):

  • RunAtLoad=true + KeepAlive.SuccessfulExit=false: dormantd starts immediately on bootstrap/login and is relaunched whenever it exits, except a clean exit 0.
  • ThrottleInterval=10: launchd will not restart dormantd more than once every 10 seconds — the only restart-rate control here, this is a crash-loop rate limiter, not a liveness check.
  • No watchdog parity. Unlike the systemd unit's WatchdogSec=150 engine-liveness ping, launchd has no built-in mechanism to detect a dormantd that is running but wedged (hung without exiting). A wedged daemon on macOS is invisible to launchd and will not be restarted; dormantctl doctor exercise <display> and the emergency-wake path (see Troubleshooting) are the operator recourse there, independent of the supervisor.
  • Composition with boot-rollback. The crash-loop / last-known-good rollback logic is entirely supervisor-agnostic — it counts dormantd process starts sharing a config fingerprint within a sliding window from its own state files, not from systemd or launchd. So on macOS, launchd's 10-second ThrottleInterval paces the restarts the same way systemd's RestartSec=2 does on Linux, and the crash-loop counter behind it still trips and rolls back to the last-known-good config after repeated failed starts — the supervisor differs, the rollback guarantee does not.

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"

Multi-machine KVM switching

What this gives you. Two machines, one OLED: pull the panel to whichever one you are using with a hotkey, the tray, the CLI, or the web — no pairing, no network protocol. Each machine writes its own input code directly over its DDC bus; ownership is observed from VCP 0x60 polling, not negotiated.

When to use it. You have one good panel connected to two machines (or one machine and one console) and would rather not buy a KVM. Not for you if you have a real KVM or a dedicated video matrix with stable EDID-controlled switching, or if either machine lacks a DDC/CI-capable output.

Quick setup. Add scope = "shared", shared_input_code, and shared_input_write_code to both ends; add shared_peer_input_code for push support. Verify each machine can read and write VCP 0x60:

dormantctl doctor ddcci

One physical monitor can serve two dormant instances through a KVM or a multi-input panel. Mark that display shared on both machines and give each machine its own input-source code. Selection is a direct local DDC write — there is no network protocol, no peer transport, no pairing, no crypto.

Set up a shared display

  1. Find the read-back code for each machine. Select the machine's input on the panel, then run:

    ddcutil --bus <N> getvcp 60
    dormantctl doctor ddcci
    

    Record the 0x60 value on that machine — this is the READ code, what the panel reports when this input is active. Switch the monitor to the other input and repeat there.

    Next, verify the write code. Most panels use the same value for both read and write, but some (e.g. certain AOC AGON models) accept a different code on setvcp 60 than they report on getvcp 60. Test it:

    # Stop dormantd and any other ddcutil users first, then:
    ddcutil setvcp 60 0x0f --bus <N>   # try the read-back value
    ddcutil getvcp 60 --bus <N>          # did it switch?
    

    If the write fails silently (the panel stays on the old input), probe the codes advertised in ddcutil capabilities --bus <N> for the VCP 60 feature — the panel may list an "Unrecognized value" that is actually the working write code. Try each candidate; confirm with a physical check. Record the working write value as shared_input_write_code.

  2. Mark the same physical display shared in both configurations. Each machine uses its own code:

    [displays.shared_oled]
    controllers = ["ddcci"]
    blank_mode = "power_off"
    scope = "shared"
    shared_input_code = 0x0f       # what getvcp 60 reports for this input
    shared_input_write_code = 0x15 # what setvcp 60 needs (omit if same)
    

    If the peer machine sits on a different input, also configure its codes so push works:

    shared_peer_input_code = 0x10       # what getvcp 60 reports for the peer's input
    shared_peer_input_write_code = 0x15 # what setvcp 60 needs for the peer (omit if same)
    

How switching works

There is no network pairing, no mDNS discovery, no cryptographic handshake, and no cross-host protocol. Every switch is a direct local DDC write on the acting machine's own bus — the machine writes an input code and the panel moves.

PULL — write my input code

dormantctl switch <display>, the tray hotkey, the tray "Switch to here" menu item, the web UI "Switch to here" button, and a local activity edge (when activity_follow is on) all pull: they write the acting machine's own shared_input_write_code to the panel's VCP 0x60.

Pulls are always safe: the acting machine is awake, its output is driving signal, and the write lands.

# Pull the panel to this machine (write my input code)
dormantctl switch shared_oled
# Push the panel to the peer (write the peer's code; requires shared_peer_input_write_code)
dormantctl switch shared_oled --to-peer

PUSH — write the peer's input code

dormantctl switch <display> --to-peer and the web UI "Send to peer" button push: they write the peer's code. This path exists only when shared_peer_input_write_code is configured on the display; without it the CLI returns "not configured" and the web affordance is absent.

Push is deliberately minimal — no wake, no retry. If the peer's output is not driving signal, the write is ACKed by the DDC bus but the panel silently ignores it (see Signal-presence law). Push is for the common case: both machines are awake and the operator wants to switch away without reaching the other keyboard.

Semantic read/write aliases

shared_input_code is what the panel reports at VCP 0x60 when this input is active; shared_input_write_code is what you write to select it. On the maintainer's AOC AG326UZD these differ: write 0x15, read back 0x10. A write is verified against the READ alias — a mismatch is a write_input_source failure.

shared_peer_input_code and shared_peer_input_write_code are the peer-side pair. When the peer READ alias is absent, the push verification degrades to "changed away from my code" and the daemon logs kvm_push_verification_degraded.

Ownership — an observation, not authority

Each machine reads its own VCP 0x60 every poll_interval (default 2s). Ownership is a local observation of what the panel reports — the daemon never broadcasts "I own the panel" to a peer, and nothing consults ownership before writing.

Ownership gain and loss are debounced differently depending on the path:

  • Gain observed by the poll follows the same loss_confirmations (default 3) consecutive agreeing reads as loss — a single "mine" reading does not immediately commit ownership. This defends against cross-machine DDC collisions returning the same wrong code N times (issue #134).
  • Gain from a verified local pull (dormantctl switch, tray, web, hotkey, activity follow) calls mark_owned_immediate after the write-verification readback confirms the panel moved — the machine has first-hand proof of ownership, so the poll's debounce is bypassed. There is no ~7.5 s wake lag on the acquiring side (coordination.rs:305, direct_switch.rs:258).
  • Loss is always debounced through loss_confirmations consecutive "not mine" reads. With defaults a loss takes ~6 seconds to commit (2s × 3). During that window the old owner still believes it owns the panel and may issue a blank that can land on top of the new owner's wake. Lower loss_confirmations toward 1 to shrink the window at the cost of flap-susceptibility.

A read whose observed code is neither the local nor the peer input code is treated as a transport failure — the prior verdict is held and the debounce counter is not incremented. Only observations that classify as Local, Peer, or a definite debounce-influencing pattern reach the state machine (coordination_poll.rs:124-137, issue #138 part B). A read failure also holds the prior verdict.

Convergence, not mutual exclusion

There is no cross-host lock. Two genuinely simultaneous activity edges on different machines both write once and the panel settles to whichever write arrived last; both fire acquire hooks, and the loser self-corrects on its next poll. Guaranteed by design and test-proven: at most one write per admitted local edge, zero poll-caused writes, no sustained ping-pong. The rare double-flip is the accepted cost of a lock-free design — do not expect serialization we don't provide.

Activity follow

When coordination.activity_follow = true (default false), a genuine local activity edge — keyboard, mouse, or tablet input — pulls the shared display to this machine after arm_after idle (default 7s). The jiggler and ignored-device filter mean an ignored device produces no edge.

coordination.cooldown (default 3s) suppresses only activity-driven pulls. Hotkeys, CLI switches, tray actions, and web buttons deliberately bypass cooldown — an explicit operator action is never swallowed.

Activity follow has a warm-up: a pull only fires if the input source was not already local (the display is either showing the peer or an unknown source).

Hooks

Each shared display can declare action slots. Actions in a slot run in declaration order. A hook action is either a command (argv array, no shell) or an MQTT publish (QoS 1, non-retained).

Hook slots and their timing

SlotDirectionWhen it fires
before_acquirePull (acquire)BEFORE the local DDC write. Blocking — a failure aborts the pull. This is the initiator's wake-and-verify slot.
after_acquirePull (acquire)AFTER a successful pull. Fire-and-forget — the pull already completed.
before_releasePush (release)BEFORE the peer DDC write. Blocking — a failure aborts the push. Use for USB-switch or KVMP transitions.
after_releasePush (release)AFTER a successful or failed push write. Fire-and-forget.
on_observed_lossPoll (loss)AFTER the poller commits an ownership loss. Fire-and-forget — the poll path has no write authority and must never trigger a corrective DDC write or retry.

Hook causality — who gets advance notice

The initiating machine gets real before_acquire / after_acquire timing — blocking, local, before its own write.

The machine that loses the panel to a peer's pull gets no advance notice — it learns from its own poll afterward and fires the on_observed_loss after-only slot. before_release never fires post-hoc.

Hook environment

Hook commands run in a deliberately minimal environment — the daemon's build environment and unrelated secrets must not leak into a hook child.

Every hook command receives:

VariableSourceMeaning
PATHhard-coded/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin — the daemon's PATH is never inherited
HOMEdaemonInherited so ~ resolution works
WAYLAND_DISPLAYdaemon (if set)Wayland compositor socket
XDG_RUNTIME_DIRdaemon (if set)Per-user runtime directory
DISPLAYdaemon (if set)X11 display (XWayland)
XDG_SESSION_TYPEdaemon (if set)wayland or x11
DBUS_SESSION_BUS_ADDRESSdaemon (if set)User D-Bus session bus
DORMANT_DISPLAYcontextConfig display id
DORMANT_DISPLAY_IDENTITYcontextClaim identity (manufacturer:model[:serial])
DORMANT_DIRECTIONcontextacquire or release or observed_loss
DORMANT_PHASEcontextbefore or after
DORMANT_PEERcontextPeer's instance id (not display name)
DORMANT_FALLBACKcontext0 or 1 — whether this slot is firing on the fallback path
DORMANT_ABORTEDcontext0 or 1 — whether this is a write-failure compensation channel

Nothing else is inherited. If the daemon does not have a session var (e.g. WAYLAND_DISPLAY on a non-Wayland session), it is simply absent from the child — no empty-string injection.

A blocking = true before_acquire hook is an abort gate. If the hook fails for any reason (non-zero exit, timeout, spawn failure, missing binary), the switch is cancelled and claim_acquire_aborted is logged. An environmentally-broken hook is therefore a correctness risk — verify your hook commands run successfully under the hook environment before deploying. See Signal-presence law for why the before_acquire wake-and-verify slot is load-bearing.

Scheduling and idempotence

The blocking default is phase-dependent: before_* slots default to blocking, after_* slots default to fire-and-forget (hooks.rs::default_blocking_for). on_observed_loss is an after_* slot and therefore defaults to non-blocking. Entries declared blocking = true run to completion and block the next phase; non-blocking entries are spawned and the phase continues immediately. Hooks are bounded by their per-entry timeout (default 5s), not cancellable mid-run. Hook commands must be idempotent — check DORMANT_DIRECTION and DORMANT_PHASE in the environment to decide whether to act or skip.

macOS DDC/CI power-off hazard

A power_off blank on a shared macOS DDC/CI panel can be unrecoverable: the USB-C link and the panel's USB hub drop on standby, VCP writes go to a dead device, and recovery requires physically power-cycling the monitor. The full mechanism, the observed behavior on the maintainer's AOC AGON AG326UZD, and the explicit acknowledgement escape hatch (displays.<id>.power_off_opt_in = true) live in Displays → macOS shared-DDC/CI power-off hazard. Read that section before using controllers = ["ddcci"] with blank_mode = "power_off" on a shared display from a macOS host. Dormant emits a load-time semantic warning and a dormantctl doctor failure for every hazardous display — the opt-in silences both, it does not add recovery.

Signal-presence law

A monitor will not switch VCP 0x60 to an input that has no live video signal. This was verified on the AOC AGON AG326UZD with both a Linux desktop (DisplayPort) and a macOS machine (DisplayPort). The panel ACKed setvcp 60 writes to a dark input, reported them as successful, but readback confirmed VCP 0x60 stayed on the active input.

This is why push fails safe and why the daemon does not try to wake a peer: a machine whose output is dark cannot receive a panel pull. The initiating machine's own before_acquire hooks (wake the output, verify signal) are the only path to a working switch.

Linux permissions

The activity-follow path reads keyboard and mouse events. On Linux, the default input source is the Wayland compositor's idle-notifier protocol or the D-Bus screensaver idle time — neither requires elevated permissions.

When [input_filter] ignore_devices is configured, the daemon opens evdev /dev/input/event* nodes through the compositor's seat to filter out named devices. The opener needs read access to those nodes, which typically means the user running dormantd must be in the input group, or the system's uaccess / logind ACL must grant the active seat access.

Verify the backend with:

dormantctl doctor input-filter

The probe confirms that /dev/input/event* nodes are readable and reports which devices match the configured ignore_devices globs. If the probe fails with "permission denied", add the user to the input group and re-login.

macOS Accessibility

On macOS, the tray hotkey (Carbon RegisterEventHotKey) requests no Accessibility permissions. The macOS CGEvent tap (the input-filter backend for activity follow on macOS) does require Accessibility permissions.

InputWake validation

When an activity-driven pull fires and the render sink is the active stage, the daemon waits for the first real input event from the new owner — the InputWake. This proves the input source actually reached the panel and that the display is now showing the active framebuffer.

The validator is a 500 ms bounded window: if a valid edge arrives, the pull completes immediately; if the window expires, the pull still completes — the validator is a best-effort proof, not a gate.

[coordination] reference

Coordination is opt-in — it activates only when at least one display has scope = "shared". There is no enabled key; the section's presence (or any key within it) has no effect without a shared display.

KeyTypeDefaultDescription
poll_intervalduration"2s"Shared-display ownership poll cadence (VCP 0x60); 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. Ownership still polls at poll_interval; only panel state refreshes here, to cut per-transaction i2c traffic.
loss_confirmationsinteger3Consecutive agreeing VCP 0x60 readings required before the cached ownership verdict flips — symmetric for gain and loss. Defends against garbled reads from concurrent cross-machine DDC traffic (issue #134). Validated 1..=10. A verified local pull marks ownership immediately (the machine has first-hand proof), so loss_confirmations only governs the poll-observed transitions.
activity_followbooleanfalseWhen true, a genuine local activity edge (keyboard, mouse, tablet) pulls a shared display to this machine after arm_after idle.
arm_afterduration"7s"Grace window after receiving a local arm before the pull commits (only meaningful when activity_follow = true).
cooldownduration"3s"Minimum interval between successive activity-driven pulls. Hotkeys, CLI, tray, and web bypass this — an explicit operator action is never swallowed.

Example: shared display with hooks

[displays.shared_oled]
controllers = ["ddcci"]
blank_mode = "power_off"
scope = "shared"
shared_input_code = 0x0f
shared_input_write_code = 0x15
shared_peer_input_code = 0x10
shared_peer_input_write_code = 0x15

[displays.shared_oled.hooks]
before_acquire = [
  # Wake the machine's video output so the panel will accept the switch.
  { command = ["xset", "dpms", "force", "on"], timeout = "5s", blocking = true },
]
after_acquire = [
  { command = ["notify-send", "panel acquired"], timeout = "5s", blocking = false },
]
before_release = [
  # Switch the USB peripheral hub to the peer before releasing the panel.
  { mqtt = { topic = "usbswitch/set", payload = "mac" }, timeout = "5s", blocking = true },
]
on_observed_loss = [
  # The poller detected a peer pull — this machine lost the panel.
  { command = ["notify-send", "panel released to peer"], timeout = "5s", blocking = false },
]

Limits and failure behavior

  • VCP 0x60 reports the main window only. PIP/PBP can show another source while readback still names the main input; do not use shared coordination to automate a PIP layout.
  • If a third monitor input is selected, both configured machines are non-owners until one of their configured inputs returns.
  • A DDC read error holds the last ownership verdict. At cold start or after a stale observation, ownership stays conservative. An unknown zone acquiring ownership does not wake the panel.
  • Two daemons polling the same physical panel will see occasional successful-but-wrong reads on each other's bus traffic. The loss_confirmations debounce holds the prior verdict on a stray "not mine" reading; coord_poll_disagreement (when consecutive observations disagree), coord_ownership_loss_deferred (when the pending loss counter is below the threshold), and coord_ownership_gain_deferred (when the pending gain counter is in flight — the symmetric counterpart to the loss-deferred signal) are emitted as literal anchors so the operator can see the bus is dirty without parsing the verdict cache.
  • The debounce reduces but does not eliminate false losses. If cross-machine DDC collisions return the same wrong code N times in a row, a false loss can still commit — N=3 makes a false loss ~3× less likely than N=1, not impossible. The coord_poll_disagreement signal only fires when consecutive observations differ; identical wrong readings sail through the debounce.
  • dormantctl doctor can report input_source=skipped when a controller has no usable input-source readback, or input_source=unreadable when a read was attempted and failed. Fix that before relying on a shared display.

Web UI network surface

The only remote control surface is the web UI. Under default configuration the web server binds 127.0.0.1 only. Binding a non-loopback address is rejected at startup unless daemon.web_allow_nonloopback is explicitly set to true.

If web_allow_nonloopback = true is set, the following unauthenticated endpoints become reachable from the LAN:

  • POST /api/switch — switch display input
  • POST /api/push — push the panel to the peer (write the peer input code; requires shared_peer_input_write_code)
  • POST /api/blank — blank the panel
  • POST /api/wake — wake the panel
  • POST /api/pause / POST /api/resume — pause or resume rule processing
  • POST /api/emergency-wake — immediate global wake
  • POST /api/reload — reload config from disk
  • POST /api/config/apply — write a new config
  • POST /api/doctor / POST /api/doctor/exercise/:display — run probes
  • POST /api/pair/samsung — pair with a Samsung TV

The blank, switch, pause, and resume routes fire operator-configured hooks that run arbitrary commands (including MQTT publishes) — a wider surface than "someone can flip my monitor input." The web UI has no authentication layer of its own; it relies entirely on the loopback bind for access control. Opening the bind to the LAN removes the only barrier.

Operator migration — coordination.enabled removed

The coordination.enabled key was removed from the config schema. Prior to the direct-write pivot this key controlled mDNS discovery, instance pairing, and the claim protocol — all now deleted. Existing configs that set enabled will fail strict unknown-key validation until the key is deleted from the TOML file.

# To fix: remove the stale key from your config.
# Run validate to confirm:
dormantctl validate

If your config also carried any of the other removed coordination keys, delete those too — they no longer exist in the schema:

# All of these are gone. Delete any that appear under [coordination]:
#   enabled                 pairing_port            pairing_window
#   pairing_bind_address    claim_port              claim_bind_address
#   claim_advertise_mdns    activity_claim          owner_idle_window
#   armed_window            claim_timeout           release_deadline_cap

The six keys that survive are poll_interval, state_poll_interval, loss_confirmations, activity_follow, arm_after, and cooldown.

Sensors

What this gives you. Three sensor inputs (MQTT, Home Assistant WebSocket, USB-LD2410) with a uniform [sensors.<id>] config shape and a uniform doctor probe per type.

When to use it. Wiring the first presence source into a config. Choose MQTT for an ESP32 or Zigbee sensor, HA WebSocket for a sensor already in Home Assistant, or USB for a direct-attached HLK-LD2410 radar.

Quick setup. Pick the type, drop in one block, hot-reload:

[sensors.desk]
type = "mqtt"
broker_url = "tcp://localhost:1883"
topic = "ld2410c-desk/binary_sensor/desk_presence_desk_seated/state"
dormantctl reload && dormantctl status

The sensor appears in dormantctl status as unavailable until its first event — the default zone policy treats unavailable as present, so the display stays on.


dormant ingests presence from MQTT, Home Assistant WebSocket, and USB-serial sources. This page covers setup and the dormantctl doctor check for each.

Two ways to wire the LD2410C

The HLK-LD2410C mmWave radar is a plain serial device. Connect it through either topology below; each needs one config block.

Topologydormant typePathWhen it wins
Radar on an ESP32 → WiFi → MQTTmqttRadar → ESP32 (ESPHome) → WiFi → broker → dormantSensor placed anywhere with power; tuned live from Home Assistant; one radar can feed many consumers. Appears in HA natively over the ESPHome API.
Radar direct to the hostusb-ld2410Radar → USB-serial adapter → dormantSensor at the machine it guards: no WiFi, no broker, lowest latency. The ESP is not needed; dormant reads the raw UART frames itself.

A ready-to-flash ESPHome config for the MQTT topology ships at examples/esphome/ld2410c-c6-wifi-mqtt.yaml — it exposes near/far distance zones, per-gate sensitivity thresholds (g0–g8 move/still), an energy-gated desk_seated template that bypasses the LD2410C's presence-flag quirks, and a tunable still-energy floor, all adjustable from HA without reflashing. The USB topology needs only a type = "usb-ld2410" sensor block (see below) and a ~$2 adapter.

Both are fail-safe symmetric: broker loss (MQTT, via the retained LWT) and USB unplug both mark the sensor unavailable, which the default zone policy treats as present — dormant never blanks a room it can't see, whichever transport drops.

2D zones need different silicon. The LD2410C reports one target along a single axis (distance), so it does 1D near/far zones only. True X/Y polygon zones (as on the Everything Presence Pro) need a multi-target radar like the LD2450, which wires to the same ESP32 identically — swap the module, keep the board and config shape.

LD2410C tuning and hardware quirks

The LD2410C divides its detection space into 9 gates. Each defaults to a 75 cm radial slice, and each gate has independent moving and static energy thresholds (0–100; lower = more sensitive). A target is reported only when the measured energy exceeds that gate's threshold.

GateApprox. rangeDefault moveDefault stillNotes
00–75 cm500Still threshold not hardware-settable per HLK datasheet; any value written is ignored.
175–150 cm500Still threshold not hardware-settable, same as gate 0.
2150–225 cm4040
3225–300 cm3040
4300–375 cm2030
5375–450 cm1530
6450–525 cm1520
7525–600 cm1520
8600–675 cm1520

Gate 0/1 limitation. A person sitting still within ~150 cm of the sensor cannot be detected as a still target — gate 0 and 1 have no settable static sensitivity, and their moving thresholds (50) are the highest of any gate. A desk-mounted sensor is therefore the worst case: the occupant sits in the gates with no static detection and the strictest moving threshold.

Mounting guidance. Position the sensor so the monitored position falls in gates 2–5 (150 cm to 450 cm), where both moving and static thresholds are available and defaults are already lower. If the sensor must be close to the occupant, lower the gate 0/1 moving thresholds and use the energy-gated desk_seated template (in the example YAML) instead of the LD2410C's native presence flags — the template keys off the raw still-energy level, which rises reliably even when the flags stay absent.

Re-arm quirk. Multiple HA-community reports describe the LD2410C still-presence flag requiring a moving trigger to re-latch after it clears — still-energy above the gate threshold alone does not always re-arm has_still_target. The energy-gated desk_seated template avoids this flag and reads the energy level directly, so it is immune to the re-arm quirk.

Staleness. Binary sensors publish occupancy on-change only, so a long still period (occupant seated, no movement) silences the state topic until dormant's sensors.<id>.stale_timeout fires and the sensor reads unavailable. The fail-safe (default zone policy = "present") keeps the display on, but occupancy-driven wake-on-return is lost until the next MQTT edge. The example YAML therefore ships a 5-minute heartbeat: an interval block re-publishes the current desk_seated state (retained) even when unchanged, so topic silence again means "device gone" rather than "occupant still". Set stale_timeout to a bit over one heartbeat (e.g. "6m") and the sensor only goes unavailable when the ESP genuinely drops off the network. When the sensor's broker publishes a retain-able LWT (see Retained values and availability below), an explicit online availability frame outranks topic silence: a sensor with a healthy LWT stays Present across minutes of unchanged occupancy, and only goes Unavailable on offline / broker failure. The stale_timeout then becomes a backstop for sensors without an LWT topic.

Setting per-gate thresholds is necessary but not sufficient to fix close-range seated detection on its own — the gate 0/1 hardware limit and the re-arm quirk remain. The energy-gated template is the primary mitigation; per-gate thresholds fine-tune the radar for the gate range where the occupant actually sits.

Calibrating the floor and gates. The most reliable tuning method is a marked-scenario data run: record still-energy values for each scenario (away, seated, standing, couch, walking) with the sensor in its final position. Compare the per-scenario distributions and set the still-energy floor between the worst-seated-still measurement and the best-absent measurement, leaving margin on both sides. When a couch or sofa sits behind the desk, prefer cutting the max still gate over raising the floor — stopping the radar from reporting targets beyond the desk in-hardware prevents false presence at the source and stabilizes the reported detection distance against far-wall echoes.

Switching an existing sensor over to desk_seated. The template publishes on its own MQTT topic, so an existing [sensors.*] block keeps reading the old distance-gated entity until you repoint it. After reflashing, confirm the new topic is live and then update the config:

# 1. confirm the ESP is publishing the new entity
mosquitto_sub -h <broker> -v -t 'ld2410c-desk/binary_sensor/#' -W 5

# 2. point the sensor at it (topic slug follows the entity name)
#    topic = "ld2410c-desk/binary_sensor/desk_presence_desk_seated/state"

# 3. hot-reload — no daemon restart needed
dormantctl reload && dormantctl status

Verify the slug from the mosquitto_sub output rather than assuming it: ESPHome derives it from the entity name, so a renamed template changes the topic. Keeping the old desk_near subscription is also valid — both entities publish independently, and desk_near remains the better signal for the far zone.

MQTT

dormant connects as an MQTT client (using rumqttc, pure Rust, no system dependency). It subscribes to one topic per sensor and reads a JSON pointer into each payload.

Broker compatibility

Any MQTT 3.1.1 broker works: Mosquitto, EMQX, HiveMQ, Zigbee2MQTT built-in broker, Home Assistant MQTT add-on.

Payload format

By default, dormant reads the /occupancy JSON pointer. For a Zigbee2MQTT sensor, the payload looks like:

{
  "occupancy": true,
  "illumination": 245,
  "no_occupancy_since": 15
}

If your sensor publishes a different field, set field (e.g., field = "/presence"). If payloads are not JSON (raw ON/OFF text), set payload_on = "ON" and payload_off = "OFF" — these override the default JSON boolean interpretation.

For an authenticated broker, keep credentials out of config.toml:

# ~/.config/dormant/credentials.toml
[mqtt."tcp://localhost:1883"]
username = "dormant"
password = "secret"

The table key must match sensors.<id>.broker_url exactly.

Retained values and availability

MQTT retained values are dispatched like live publishes on initial subscribe and reconnect. This gives an on-change sensor a state immediately after daemon restart instead of leaving it unavailable until the next physical edge.

MQTT retained values carry no timestamp. dormant starts the sensor's sensors.<id>.stale_timeout clock when it receives one, even if the retained value is old. A stale retained present or absent value can therefore remain authoritative until that timeout expires (or daemon.stale_sensor_timeout, default 5m, when no per-sensor override is set).

For an on-change-only Zigbee2MQTT sensor, enable retained state on the device. Without it, the broker has no current occupancy value to send after a restart.

  1. Open the Zigbee2MQTT web UI → Devices → select the sensor.
  2. Open its Settings tab and enable Retain (some Z2M versions list it under "Advanced").
    • Or set it directly over MQTT: publish to zigbee2mqtt/bridge/request/device/options with payload {"id": "<friendly_name>", "options": {"retain": true}}.
  3. Restart dormant (or wait for its next broker reconnect) and confirm the sensor shows its real state without needing a fresh physical trigger.

By default dormant derives <topic>/availability and expects "online" / "offline". Override these per sensor for a different LWT convention:

KeyTypeDefaultDescription
sensors.<id>.availability_topicstring<topic>/availabilityOverride the derived availability topic
sensors.<id>.availability_payload_onlinestring"online"Payload meaning the device is reachable
sensors.<id>.availability_payload_offlinestring"offline"Payload meaning the device is unreachable; emits Unavailable
[sensors.desk]
type = "mqtt"
broker_url = "tcp://localhost:1883"
topic = "tele/desk/SENSOR"
field = "/PIR"
availability_topic = "tele/desk/LWT"
availability_payload_online = "Online"
availability_payload_offline = "Offline"

The online/offline payloads must be non-empty and different. Sensors sharing one resolved availability topic on a broker must use the same payload pair. An unknown payload logs one warning per (topic, sensor) pair and is ignored.

How availability gates the stale timeout (issue #136). A matching online payload is the source's reachability claim: dormant records it in the rules engine's availability_online set, and the stale-sensor sweep leaves the sensor alone on state-topic silence. This lets a seated occupant (no motion, no occupancy re-publish) keep the screen on for minutes without a fresh state edge. The claim is deliberately NOT a presence refresh — last_seen still advances on real wall-clock time, so a later broker failure is detectable. A matching offline payload (LWT) immediately demotes the sensor to Unavailable and clears the claim, and a broker disconnect emits Unavailable for every owned sensor (which also clears the claim), so a dead connection can never preserve stale presence forever. Sensors WITHOUT an availability topic keep the pre-#136 behavior exactly: silence past stale_timeout marks them Unavailable. Keep the 6-minute stale_timeout guidance for sensors whose bridge does not publish LWT.

Every sensor starts unavailable. The snapshot's reported diagnostic records whether that sensor has delivered any event since daemon start. The web dashboard marks unavailable sensors with reported == false as "no data since start." The bit survives reload when the sensor binding is unchanged and resets when that binding changes.

Stale retained-vacant risk

Consider a single-sensor zone whose broker holds an old vacant value while someone sits motionless in the room:

  1. t = 0 — the daemon starts, subscribes, and immediately receives the retained vacant value. This is a real Absent occupancy event to the rules engine — retained-vacant on the occupancy topic bypasses unavailable_policy entirely; it isn't an availability signal.
  2. With default timings (grace_period = 60s, startup_holdoff = 30s), the grace countdown starts at t = 0. Because grace (60 s) outlasts holdoff (30 s), holdoff has already elapsed by the time grace expires, so nothing else gates the blank.
  3. t ≈ 60s — grace expires and the display blanks, even though the occupant is still there, motionless. The stale retained vacant was never corrected by any real edge.
  4. The display wakes on the occupant's very next detected movement — the wake path is never gated by grace or holdoff.

With default timing, the display can blank about 60 seconds after restart despite the occupant. dormant cannot distinguish the old retained value from a fresh publish. The next detected presence edge wakes the display.

Do not set zones.<id>.unavailable_policy = "absent" for an MQTT sensor until you have verified that its bridge republishes occupancy when availability returns. An offline payload maps the sensor to Unavailable; under the "absent" policy that can blank the screen. If the bridge does not republish occupancy on recovery, only a later state publish can wake it. The default "present" policy keeps the display on. dormant logs unavailable_absent_mqtt for this combination but does not reject it.

Doctor check

dormantctl doctor mqtt

Verifies: broker reachability, topic subscription, payload parsing (last received value). If the check fails, verify the broker URL, topic spelling, and network connectivity.

Home Assistant WebSocket

Connects to the HA WebSocket API and subscribes to entity state changes. Requires a long-lived access token in the credentials file.

Setup

  1. Create a long-lived access token in HA: Settings → People → your user → Long-Lived Access Tokens.
  2. Store it in ~/.config/dormant/credentials.toml:
    ha_token = "eyJ..."
    
  3. Set file permissions:
    chmod 600 ~/.config/dormant/credentials.toml
    

Entity types

Any binary sensor entity works: door sensors, motion sensors, mmWave presence sensors. The entity must report a state convertible to occupied/vacant. dormant interprets "on"/true as occupied and "off"/false as vacant.

Doctor check

dormantctl doctor ha

Verifies: WebSocket connection, authentication, entity subscription, last known state. If authentication fails (E_HA_AUTH), check the token and credentials file permissions. If the entity is unknown, verify the entity ID spelling and that the entity exists in HA.

USB-serial LD2410

Reads presence data from an HLK-LD2410 mmWave radar module connected via USB-serial (CH340/CP2102 adapter).

Hardware

  • HLK-LD2410 (or B/C variant): 24 GHz, detects stationary and moving targets up to ~6 m / ~4.5 m respectively.
  • USB-to-serial adapter: CH340 or CP2102, 5 V power, 3.3 V logic UART.
  • Default baud: 256000, 8N1.

Permissions

Your user must have read/write access to the serial port. On most distributions, add your user to the dialout group:

sudo usermod -a -G dialout $USER
# Log out and back in for the group change to take effect

Doctor check

dormantctl doctor usb /dev/ttyUSB0

Verifies: serial port accessibility, baud rate negotiation, frame parsing, last reported state (moving/stationary targets, distance). If the port is not found (E_SENSOR_IO), check the device path (ls /dev/ttyUSB*) and group membership.

Tuning

The LD2410 has configurable sensitivity and detection ranges. See the LD2410C tuning and hardware quirks section above for the gate table, the gate 0/1 static-sensitivity limitation, mounting guidance, and the re-arm quirk — those apply regardless of transport.

For the USB-serial topology, thresholds are set via the module's own serial protocol (outside dormant's scope). Use the manufacturer's PC tool or ESPHome to configure them. Common adjustments:

  • Reduce maximum detection range in small rooms (default 6 m is often too sensitive)
  • Increase "no occupancy" delay if the sensor flickers (minimum 15 s on consumer modules)

Sensor kinds

KindBehavior
"presence"Binary: occupied or vacant. Stable.
"motion"Transient: triggers on motion, clears after hold time. Good for hallways and pass-through areas.

Motion sensors use the hold_time to bridge gaps between motion pulses. A 5-minute hold keeps the zone occupied for 5 minutes after the last trigger — enough to prevent the TV from blanking while you are still on the couch but not moving.

Displays

What this gives you. Six blank controllers (DDC/CI, KWin DPMS, command, HA passthrough, macOS gamma-black, Samsung Tizen), escalation ladders, and two render stages — every audio-safe and panel-internal mode in one document.

When to use it. Picking a controller for a new display, diagnosing why a rule fired the wrong blank mode, or configuring a fallback chain so a display never stays on when it should be dark.

Quick setup. Write one [displays.<id>] block with a controllers list and blank_mode, then verify the controller can reach the panel:

[displays.main]
controllers = ["ddcci"]
blank_mode = "power_off"
dormantctl doctor ddcci

dormant controls displays through an ordered controller chain. If one controller fails, it tries the next. Wake commands follow the rule's configured retry schedule before escalating.

Controllers

command — shell commands

Executes arbitrary shell commands to blank and wake a display. The most flexible controller — works with any display that can be controlled from the command line.

[displays.escape]
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"]

Set modes to declare which blank modes your commands support. dormant cannot auto-detect this for shell commands, so you must be honest — declaring a mode your commands don't actually deliver leaves the screen on.

ddcci — DDC/CI (monitor control)

Controls PC monitors via DDC/CI over I2C (/dev/i2c-*). Always supports brightness-zero; supports power-off when the monitor exposes VCP 0xD6.

[displays.main]
controllers = ["ddcci"]
blank_mode = "power_off"
restore_brightness = 80

displays.<id>.restore_brightness sets the brightness restored on wake (1–100, default 80). Zero is rejected so a wake cannot restore a black panel.

I2C permissions

Your user needs read/write access to /dev/i2c-* devices. On Debian/Ubuntu, add your user to the i2c group:

sudo usermod -a -G i2c $USER

Some distributions use plugdev instead. If no i2c group exists, create a udev rule:

# /etc/udev/rules.d/99-i2c.rules
SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660"

Then:

sudo groupadd i2c
sudo usermod -a -G i2c $USER
sudo udevadm control --reload-rules && sudo udevadm trigger

Monitor compatibility

Not all monitors support DDC/CI power-off (D6 01). Run dormantctl doctor to probe your monitor's VCP capabilities:

dormantctl doctor ddcci

If power_off is unsupported, brightness_zero is always available as a fallback (DDC/CI unconditionally supports brightness control). screen_off_audio_on is not a DDC/CI mode — use a different controller for that.

The D6 probe is retried up to 3 times with a 50 ms backoff to ride out a single transient VCP read error under DDC bus contention — a concurrent coordination poll should not permanently drop power_off from supported_modes() (ddcci.rs:284-323).

When setvcp 60 is issued for a shared display, the controller immediately reads back VCP 0x60 up to 3 times (200 ms apart) to verify the write landed. A clean read carrying the wrong value is a genuine failure and returns immediately without retry; only transport-level read errors (DDC bus noise, concurrent peer-machine traffic) are retried (ddcci.rs:633-716).

ha-passthrough — Home Assistant passthrough

Calls arbitrary HA services for blanking and waking. Use this when your display is controlled through an HA integration (smart plug, IR blaster, media player).

[displays.tv_plug]
controllers = ["ha-passthrough"]
blank_mode = "power_off"
ha_url = "http://ha.local:8123"
blank_service = "switch.turn_off"
blank_data = { entity_id = "switch.tv_power" }
wake_service = "switch.turn_on"
wake_data = { entity_id = "switch.tv_power" }
modes = ["power_off"]

The ha_token goes in the credentials file, not in the main config.

kwin-dpms — KWin DPMS

Controls KDE KWin outputs via kscreen-doctor --dpms. Per-output DPMS works on Plasma 6.7.2+ (Wayland). Audio-unsafe: DPMS disables the DRM/KMS output, which destroys the ALSA audio device for that output. Use only for displays with no audio sink and no DDC/CI.

[displays.desk]
controllers = ["kwin-dpms", "ddcci"]
blank_mode = "power_off"
output = "DP-1"

See docs/research/2026-07-05-kwin-dpms-verification.md for the spike data.

macos-display-sleep — macOS display sleep

macOS-only, last-resort fallback controller. blank() runs pmset displaysleepnow, a real (coarse, whole-machine) hardware/firmware display sleep — every attached display sleeps together, not just one panel. wake() asserts a temporary IOPM user-activity assertion and polls CoreGraphics until every online display reports itself awake again. Only supports power_off; when it is the first controller, there is no per-display selector — omit output or set it to the literal "all".

[displays.mac]
controllers = ["ddcci", "macos-gamma-black", "macos-display-sleep"]
blank_mode = "power_off"
output = "cg:37d8832a-2d66-02ca-b9f7-8f30a301b230"
ddc_display = "1"

Use this as the tail of a fallback chain, after ddcci and macos-gamma-black — see Recommended macOS chain below.

macos-gamma-black — macOS gamma black (Quartz)

macOS-only. Blanks by writing an all-zero Quartz gamma table to a specific display via CoreGraphics — a true black-pixel overlay at the color-LUT level, not a power-state change. Only supports brightness_zero. output must be "cg:<uuid>" (lowercase CoreGraphics display UUID) — a named/absent selector is a config validation error.

[displays.oled_mac]
controllers = ["ddcci", "macos-gamma-black"]
blank_mode = "brightness_zero"
output = "cg:37d8832a-2d66-02ca-b9f7-8f30a301b230"

Restart/crash behavior — read before relying on this in production. There is no periodic reassertion and no restore-on-drop: the first successful blank() per display captures the pre-blank gamma table in-process, and wake() replays exactly that captured table. That capture lives only in the running daemon's memory.

  • A dormantd restart mid-blank does not itself flash the desktop — the in-memory captured table is simply gone, and a wake() with nothing captured is a no-op.
  • But dormant also writes a breadcrumb file ($XDG_STATE_HOME/dormant/gamma-blank.json, falling back to ~/.local/state/dormant/gamma-blank.json) before every gamma blank, cleared only after a confirmed wake. On the next dormantd startup, before any config is loaded, a leftover breadcrumb triggers a system-wide CGDisplayRestoreColorSyncSettings() restore — so a crash does not permanently strand the panel black, but it does mean the panel visibly un-blacks for a moment at the next daemon start, and the rules engine may then re-blank it immediately if presence still calls for it.
  • A dead daemon does not strand the panel any more black than it already is, but it also cannot fix it. Use dormantctl emergency-wake for that — it restores from the same breadcrumb independently of daemon health. See macOS: gamma emergency restore.

For an OLED panel on macOS reachable over DDC/CI, the plan-recommended fallback order is:

[displays.oled_mac]
controllers = ["ddcci", "macos-gamma-black", "macos-display-sleep"]
blank_mode = "power_off"
output = "cg:37d8832a-2d66-02ca-b9f7-8f30a301b230"
ddc_display = "1"

ddcci first (audio-safe, panel-internal, per-monitor); macos-gamma-black next when DDC/CI is unavailable or unsupported (also audio-safe, but only a color-LUT black, not a real power-off, and with the restart caveats above); macos-display-sleep last (a real hardware sleep, but whole-machine and with no audio-safety guarantee — treat it as the fallback of last resort). As a fallback member, macos-display-sleep ignores the per-display output selector; the cg: selector above targets macos-gamma-black.

samsung-tizen — Samsung Tizen TV

Controls Samsung Tizen (OLED) TVs via KEY_PICTURE_OFF remote key over WebSocket (port 8002) and via Samsung IP Control G2 JSON-RPC (HTTPS port 1516, backlightControl) for the audio-safe brightness_zero mode. Verified on S90D (QA65S90DAKXXA). Requires a persistent socket with keepalive — the TV silently drops idle connections. Use REST /api/v2/ PowerState for real panel state, not socket liveness. Two standby depths exist (warm network-standby / deep standby); see the spike doc for the wake matrix.

Two blank modes are audio-safe:

  • screen_off_audio_on: KEY_PICTURE_OFF — true picture-off. Audio continues on the TV speakers but the HDMI source is paused and the panel is dark. Verified end-to-end on S90D.
  • brightness_zero: Samsung IP Control G2 backlightControl → 0. Near-black dim, not true-off — the HDMI source keeps running and audio plays uninterrupted. Use this when the operator wants audio playing while the panel is unreadable. The TV's backlight range is 0–50; dormant saves the current value on the first blank and restores it on wake (first-blank-wins: a re-blank while already dimmed does not clobber the saved value).

The optional displays.<id>.samsung_restore_backlight key (1–50, defaults to 50) sets the backlight value restored on wake when no saved value is available — daemon restart, reload, or first wake. The default 50 (the max on the 0–50 scale) is the fail-safe-toward-screens-on fallback: a too-bright panel is acceptable, a stuck-dim one is not.

brightness_zero is a softer panel-state change than screen_off_audio_on and may be preferable for OLED longevity in the long run (no panel power cycling), but it does not produce a true pixel-off — it only dims.

The token goes in the credentials file:

[samsung]
"192.0.2.7" = "eyJ..."

Pair from the CLI, then accept the prompt on the TV:

dormantctl pair samsung 192.0.2.7

The web config editor offers the same handshake through its Samsung pairing wizard.

See docs/research/2026-07-05-s90d-verification.md for the full spike data including wake matrix, latency measurements, and socket survival findings.

Fail-safe wake contract

Every controller must satisfy three invariants for wake():

  1. Idempotent — safe to call on an already-awake display.
  2. Retries or escalates — must not silently give up. Internally retry, or let the executor's chain handle it.
  3. No permanent failure state — a screen that won't wake is the worst outcome. Controllers must report failures clearly so the user can intervene.

Doctor checks

dormantctl doctor ddcci

Verifies: controller reachability, supported modes vs configured mode, last known state, and performs a dry-run capability probe (does not blank the display).

To verify the full control path against one configured display:

dormantctl doctor exercise main

This runs blank → read → wake → read → restore through the daemon's live controller chain. It briefly blanks the panel. See Control-path verification.

Audio-safe blanking

DPMS-based controllers (kwin-dpms, command with xset dpms) disable the DRM/KMS output, which destroys the ALSA audio device for that output. Audio stops when the display blanks. This is how the kernel DRM pipeline works — it is not configurable.

Two controllers blank without touching the output, preserving audio:

ControllerMechanismAudio-safe because
ddcciVCP 0xD6 (monitor-internal command over I2C)Panel blanks internally; OS output stays active
samsung-tizenKEY_PICTURE_OFF (TV-internal command over WebSocket) or IP-Control backlightControl (dim, near-black)TV blanks/dims panel; HDMI output stays active

Per-display strategy:

  1. If the display has DDC/CI and supports VCP D6 → use ddcci power_off.
  2. If the display is a Samsung Tizen TV → use samsung-tizen picture-off.
  3. If the display has no DDC/CI and no audio → kwin-dpms is fine.
  4. If the display has audio but neither DDC/CI nor Tizen → use a command controller with an audio-safe external command (e.g. a TV-specific IR blaster or HA automation), or accept the audio loss.

Run dormantctl doctor to probe DDC/CI VCP D6 support. For Tizen TVs, the doctor check verifies WebSocket reachability, token validity, and REST PowerState.

Escalation ladder & audio-safe black

When a hardware blank mode fails (controller unreachable, capability missing), dormant can fall back to a software render — a fullscreen overlay drawn directly on the output via the Wayland layer-shell protocol.

render_black — audio-safe black overlay

A fullscreen black layer-shell overlay that covers the entire output. The panel stays on (no DPMS, no VCP power-off), so audio continues playing through any sink attached to that output. Input (mouse/keyboard) or a presence event tears it down instantly; the cursor is hidden while the overlay is up.

This is the preferred first stage in an OLED escalation ladder when the display doubles as an audio sink:

[displays.oled]
controllers = ["ddcci"]
modes = ["power_off"]
ladder = [
  { kind = "render_black", dwell = "30s" },
  { kind = "power_off" },
]
output = "DP-1"

Here render_black buys 30 seconds of audio-safe, cursorless black before the panel actually powers off. If the sensor reports presence during those 30 seconds, the overlay vanishes — no wake latency, no re-handshake.

When to use it

  • OLED + audio over monitor: the panel powers off via DDC/CI or Tizen, but you want audio to keep playing during a short absence.
  • DDC/CI fallback: the primary power_off controller is unreachable; the ladder falls through to a render stage instead of leaving the screen on.
  • KWin DPMS replacement: DPMS destroys the DRM/KMS output and its audio sink; render_black preserves both.

Build requirement

The render backend is off by default. Build with:

cargo build --release --features render

Without the render feature, configs containing a render stage are rejected at startup with error E_RENDER_UNAVAILABLE. On Linux, the render backend also requires libwayland-dev and libmpv-dev at build time.

Pixel shift

The render_screensaver surface shifts by 2 px every 2 minutes by default. Set displays.<id>.screensaver.shift_px = 0 to disable it, or tune displays.<id>.screensaver.shift_interval (minimum 10s). Pixel shift never applies to render_black: a uniform black field has no static content to move.

With order = "wear-even", the screensaver uses host-side luma estimates and local wear heat to choose the next item. Missing data keeps the configured order and emits a fallback warning. wear_tag labels source items. The black overlay contributes zero wear and never shifts; shift_heat_bias only changes the probability of existing offsets and never exceeds shift_px.

Manual-only displays

A display listed in [displays] that no [rules] entry references is manual-only: the daemon builds a full executor and controller chain for it, and it appears in dormantctl status / the web UI / the tray app, but no zone or rule drives it. It responds exclusively to manual control commands (dormantctl blank <id>, dormantctl wake <id>) and the web/tray interfaces.

A ladder requires a rule. Validation rejects a ladder on a rule-less display with E_CONFIG_INVALID:

display 'tv' has a ladder but is in no rule; a ladder is an auto-escalation
that needs a rule to drive it — use blank_mode for manual-only control,
or add a rule

Use blank_mode (or blank_mode + degraded_mode) for manual-only displays.

Manual-only phase is preserved across config reloads (SIGHUP / dormantctl reload). A display you blanked stays blanked. However, a full daemon restart loses state — the display starts active (phase persistence to disk is not implemented in v1).

# A Samsung Tizen TV controlled entirely by hand.
[displays.tv]
controllers = ["samsung-tizen"]
blank_mode = "screen_off_audio_on"
host = "192.168.1.50"

macOS shared-DDC/CI power-off hazard

A power_off blank on a shared macOS DDC/CI panel can be unrecoverable. Read this before relying on controllers = ["ddcci"] with blank_mode = "power_off" on a display that is also wired to another machine.

The hazard topology is a small, well-defined intersection of config choices:

  1. The host is a macOS machine running dormantd.
  2. The display is scope = "shared" (multi-machine KVM switching).
  3. The display's first controller is ddcci (a ddcci later in the fallback chain does not trigger the hazard — the primary path matters).
  4. The display's primary blank mode is power_off (screen_off_audio_on and brightness_zero are audio-safe alternatives that do NOT drive a panel standby).

Observed on the maintainer's AOC AGON AG326UZD wired over USB-C to a shared macOS host:

  • The panel standby path drops the USB-C link.
  • The panel's USB hub disappears from the host's USB tree.
  • The panel's OSD becomes unresponsive (no input-source menu, no VCP readback).
  • VCP 0x60 writes (input switching) and VCP 0xD6 writes (power) succeed at the bus level but the panel ignores them — the link is gone.
  • A second power_off write goes to a dead device — the warning message in the logs reads like a normal command failure, not a physical disconnect.
  • Recovery requires physically power-cycling the monitor (AC off, AC on). A setvcp 0xD6 1 after recovery returns the panel to On, but nothing dormant can issue reaches the panel between standby and physical recovery.

Dormant does not have a software recovery path for this state. The link and hub are gone; VCP writes have no effect; dormantctl wake, dormantctl emergency-wake, and doctor exercise cannot restore the panel until it physically powers back on.

What dormant does about it

Dormant surfaces this hazard at two points so the operator cannot land in it silently:

  1. Config loadload_config emits a semantic warning at every load (both Strictness::Strict and Strictness::Warn modes):

    power_off on this shared macOS DDC/CI topology can be unrecoverable: USB-C link and hub may drop; set power_off_opt_in = true only after testing physical recovery

    The CLI surfaces this through dormantctl validate and the web config editor surfaces it through the Apply panel.

  2. Doctordormantctl doctor emits a Fail probe named macos-power-off-hazard with the display id as subject and the detail "power_off on this topology risks unrecoverable standby (USB-C link drops)". The probe is read-only and never blanks or wakes the panel.

Both paths classify the topology identically (dormant_core::config::validate::is_macos_power_off_hazard) so they agree on which displays are hazardous.

Acknowledging the hazard

Set displays.<id>.power_off_opt_in = true to silence both surfaces. This is an explicit operator attestation that physical recovery has been tested on the actual hardware. The opt-in adds no recovery mechanism of its own — it is acknowledgement, not mitigation. If the link drops, the operator still has to power-cycle the panel by hand.

[displays.shared_oled]
controllers = ["ddcci"]
scope = "shared"
shared_input_code = 0x0f
shared_input_write_code = 0x15
blank_mode = "power_off"
power_off_opt_in = true   # acknowledge: tested physical recovery

Non-hazard alternatives on macOS

Two ways to avoid the hazard entirely:

  1. Soften the primary modescreen_off_audio_on or brightness_zero are audio-safe and do not drive a panel standby on the USB-C link. With macOS samsung-tizen they reach the panel over the network, not the USB hub.

  2. Lead with macos-gamma-black — the gamma controller does not power-cycle the panel at all. Putting macos-gamma-black first and ddcci second keeps DDC available as a fallback while avoiding the USB-C standby path on the primary blank:

    [displays.shared_oled]
    controllers = ["macos-gamma-black", "ddcci"]
    scope = "shared"
    shared_input_code = 0x0f
    blank_mode = "power_off"
    

    On a macOS host this chain blanks via the gamma controller (no standby) under normal conditions and falls back to ddcci only when the gamma controller cannot reach the panel. The hazard applies only when ddcci actually delivers the blank, which is rarer here.

Both alternatives preserve the panel's standby path. The dormantctl doctor probe stops emitting the failure the moment either change lands.

Panel-wear tracking

What this gives you. Brightness-weighted on-hours per display, visible in the web dashboard and via GET /api/wear, with an advisory after no long standby window has occurred in 96 h.

When to use it. When you want to know whether your panel is getting the long dim it needs. Tracking is on by default; turn it off with wear.enabled = false if on-hours accounting is not useful for your setup.

Quick setup. Tracking is on by default — the dashboard panel-exposure card and dormantctl status are the readout:

dormantctl status

dormant records brightness-weighted panel on-time and shows it in the web dashboard. v1 measures and advises; it does not alter blank/wake timing.

What it records

For displays with readable brightness (ddcci and samsung-tizen), the tracker samples panel state every wear.sample_interval (default 60s) and records:

  • total_on_hours — brightness-weighted on-time. One hour at 50% brightness adds 0.5 hours. Blanked, blanking, and waking time adds zero.
  • seeded_usage_hours — a DDC/CI panel's lifetime VCP 0xC0 counter, read once when a new ledger is created and available.
  • last_long_dwell_epoch_s — the last blanked dwell lasting at least wear.short_cycle_dwell (default 10m).

render_screensaver attributes each loaded source frame from its luma grid and local wear heat when wear-even metadata is available. The black overlay attributes zero. Missing luma, heat, or journal data fails open to uniform attribution and logs wear_screensaver_luma_fallback; scan and journal failures also emit screensaver_luma_scan_failed and screensaver_item_journal_overflow. Playback and blanking continue.

The ledger has a wear.grid_rows × wear.grid_cols grid for future spatial attribution. v1 writes the same value to every cell. It does not know which region or content was shown.

displays.<id>.panel_type accepts "woled", "qd-oled", or "unknown" and is stored in the ledger. v1 does not change its wear formula by panel type and does not auto-detect the value.

Advisory

The panel-exposure card reports how long the display has gone without a blanked dwell of at least wear.short_cycle_dwell. After wear.advisory_after (default 96h), it shows an advisory such as:

no long standby window in 5 days

The day count is also returned by GET /api/wear. The advisory never forces a rest window, blank, or state-machine transition.

Ledger files

Each display gets one JSON ledger under:

$XDG_STATE_HOME/dormant/wear/          # when XDG_STATE_HOME is set
~/.local/state/dormant/wear/           # fallback

The filename uses the stable display identity where available: DDC/CI EDID manufacturer/model/serial, samsung:<host>, or the config display id. Writes are atomic and mode 0644; the ledger contains no credentials.

dormant persists every wear.persist_interval (default 5m), on shutdown, and when tracking is disabled at runtime. A crash can lose at most one persistence interval. Orphaned ledgers are not pruned automatically.

Delete the wear/ directory to erase the history. New ledgers are created on the next sample.

Configuration

Tracking is enabled by default. While wear.enabled = false, the tracker takes no samples and performs no ongoing ledger I/O.

[wear]
enabled = true
sample_interval = "60s"
persist_interval = "5m"
read_timeout = "2s"
grid_rows = 9
grid_cols = 16
fallback_brightness = 0.5
screensaver_factor = 0.35
short_cycle_dwell = "10m"
advisory_after = "96h"

[displays.monitor]
panel_type = "qd-oled"
KeyDefaultDescription
wear.enabledtrueEnable tracking and ledger I/O
wear.sample_interval"60s"Panel-state sample interval
wear.persist_interval"5m"Ledger write interval
wear.read_timeout"2s"Read budget for one panel sample
wear.grid_rows9Logical grid rows; uniform in v1
wear.grid_cols16Logical grid columns; uniform in v1
wear.fallback_brightness0.5Brightness fraction when readback fails
wear.screensaver_factor0.35Fixed factor during render_screensaver
wear.short_cycle_dwell"10m"Blanked dwell counted as a long rest window
wear.advisory_after"96h"Time without a long rest window before advising

Screensaver ordering is host-side estimation, not panel telemetry. order = "wear-even" uses luma and local heat; wear_temperature defaults to 0.05 and shift_heat_bias to 0.25. Flat luma buckets mean dark = .15, medium = .45, and bright = .75. Missing or overflowing grids fall back with a WARN. This milestone adds no schema-version bump, panel-type weighting, or RGB weighting.

The tracker is local-only. Its sole network surface is the loopback web API; there is no telemetry, analytics, cloud sync, or phone-home path.

Failure notifications

What this gives you. Three coordinated surfaces — desktop notification (session D-Bus, critical urgency), tray Failure state, and web failure banner — for wake and blank failures.

When to use it. A wake keeps failing and you want a persistent notice, or you suspect the desktop popup is being swallowed by the notification daemon (the tray and web banner do not depend on D-Bus). Disable desktop notifications with notifications.enabled = false; the tray and web surfaces are unaffected.

Quick setup. Notifications fire automatically when the per-display counter hits the threshold. Verify the daemon can reach the session bus:

[notifications]
enabled = true
wake_attempt_threshold = 3
cooldown = "15m"
dormantctl status   # confirm the daemon is running

When a wake command keeps failing or a blank command exhausts its controller chain, dormant surfaces the failure in three places:

  • a desktop notification over the session D-Bus (org.freedesktop.Notifications),
  • a tray icon state (Failure, outranking Paused) with a badge and tooltip detail, and
  • a web-dashboard failure banner on the Dashboard view.

The tray and web dashboard read per-display failure state directly. Only the desktop popup is gated by notifications.enabled; disabling it does not hide the tray state or dashboard banner.

What fires and when

TriggerGateUrgencyRecovery notice
Wake failureconsecutive wake attempts reach wake_attempt_threshold (default 3)CriticalYes, if notify_recovery
Blank failurea blank command exhausts its controller chain — one-shot, no thresholdCriticalYes, if notify_recovery
Recovery (wake or blank)the display succeeds its wake/blank command after a prior failure noticeNormalgated by notify_recovery itself

Two rules matter:

  • The threshold applies to wake failures only. Each failed wake attempt increments a per-display counter; a notification fires only once that counter reaches wake_attempt_threshold. Attempts below the threshold are logged (notify_suppressed, reason BelowThreshold) but produce no desktop popup. Blank failures have no threshold at all — the first blank command that exhausts its whole controller chain notifies immediately. (This means the tray/web failure state, which fires on wake_attempts > 0, can go "red" before the desktop notification threshold is reached — the tray and dashboard are more sensitive by design.)
  • The cooldown applies to both. Once a failure notification for a display has fired, a repeat of the same kind of failure on the same display within cooldown (default 15m, floor 1m) is logged (notify_suppressed, reason Cooldown) instead of re-notifying — the existing notification is left in place. After the cooldown window passes, the next failure replaces the prior notification (same D-Bus notification id) rather than stacking a new one.

Silencing it

Desktop notifications alone can be turned off without touching anything else:

[notifications]
enabled = false

With enabled = false, the notifier task is never spawned — no D-Bus connection, no session-bus traffic, no notification of any kind. The tray icon and the web dashboard's failure banner are unaffected: they read the same per-display failure state directly from the engine snapshot, not from the notifier.

Why wake failures are critical urgency

Failure notifications (wake and blank) use the freedesktop critical urgency hint; most desktop notification daemons persist a critical notification until the user dismisses it, rather than letting it time out and vanish. Recovery notices use normal urgency, since they are informational rather than something the operator must act on.

A display that will not wake is the worst failure mode for a presence daemon. Wake and blank failures therefore use loud, sticky notices; recovery is informational.

Reload carry-over semantics

Failure state and open notification episodes are daemon-lifetime, not generation-lifetime — a config reload does not reset them:

  • The notifier's episode bookkeeping (NotifyState — one open episode per (display, kind), the D-Bus notification id it maps to, and the cooldown clock) is constructed once in App::start and threaded unchanged through every reload generation, exactly like the reload-surviving ZbusSink connection. A reload does not close or re-open a still-failing display's notification.
  • The rules engine's own per-display wake_attempts / last_blank_failed counters are seeded into the freshly-built generation from the old generation's snapshot, so an in-flight failure survives a reload as far as the engine is concerned too.

The dispatch-relevant voiding rule. A reload can change how a display is driven — its controller chain, blank/wake commands, DDC/CI target, Home Assistant service calls, and so on. If that happens, the failure evidence accumulated under the old dispatch logic is no longer a trustworthy signal about the new one, so it is voided rather than carried forward: before a display's failure counters are seeded into the new generation, they are zeroed if the display's dispatch-relevant config changed (controllers, blank/degraded mode, ladder, output/DDC target, host/WoL MAC, blank/wake command or service+data, controller modes, command timeout, or the unreachable-treated-as-blanked flag) — or if the display was added or removed outright (no baseline to compare against). Fields that don't affect how a command is dispatched (screensaver, restore_brightness, samsung_restore_backlight, panel_type) never trigger voiding.

When a display's evidence is voided this way, the notifier's post-reload reconciliation sees it reporting healthy and closes any notification that was open for it — but without a recovery notice. Reconciliation never emits a recovery notice under any circumstance (unlike a genuine wake/blank recovery event caught live); this is intentional, because voided evidence isn't a real recovery — the config changed, it wasn't fixed. The same no-recovery-notice rule applies to a display removed from config entirely: its open notification is closed silently.

The daemon-restart limitation

Everything above only holds within one running daemon process — a config reload swaps generations in place, but a full daemon restart (killing and restarting dormantd) starts over with nothing:

  • NotifyState — the notifier's open-episode bookkeeping and D-Bus notification ids — lives only in daemon process memory. It is never persisted to disk (there is no on-disk equivalent of the wear ledger's $XDG_STATE_HOME/dormant/wear for notification state).
  • The rules engine's wake_attempts / last_blank_failed counters are likewise pure in-memory bookkeeping, with no persisted state file. A fresh process starts every display at wake_attempts = 0, last_blank_failed = false.

So a failure that was in flight when the daemon was killed does not re-surface after it restarts, even if the underlying hardware problem is still there — the notifier's startup reconciliation only ever sees the fresh (healthy-looking) snapshot from the new process. The failure has to recur and re-accumulate past the threshold before it notifies again. This is a known gap, not a design choice to hide restarts — flag it if it becomes a real operational pain point.

Privacy: session bus only

Desktop notifications talk to exactly one thing: the local org.freedesktop.Notifications service on the user's own D-Bus session bus (never the system bus, never a network socket). There is no telemetry, no external process, no data leaving the machine — consistent with the project's no-telemetry, no-phone-home stance.

Every D-Bus call (connect, Notify, CloseNotification) is bounded by a 2-second timeout. If the session bus is unreachable, the notifier logs notify_unreachable once and backs off for 60 seconds before trying again, rather than retrying in a tight loop or blocking anything else the daemon is doing. The notification's application identity (both the app name and the app icon hint passed to Notify) is "dormant".

Configuration reference

[notifications]
enabled = true                  # kill-switch; false = no notifier task, no D-Bus I/O
wake_attempt_threshold = 3      # consecutive wake failures before notifying (>= 1)
cooldown = "15m"                # minimum time between repeat notices per display (>= 1m)
notify_recovery = true          # send a Normal-urgency notice when a failing display recovers

See the commented [notifications] block in examples/config.toml for the same keys with inline explanations.

Watchdog + last-known-good rollback

What this gives you. Three recovery mechanisms — health-gated last-known-good (LKG) config promotion, boot-time rollback for invalid configs and counted crash loops, and a systemd watchdog — so a broken config or a wedged daemon does not strand the panel.

When to use it. You see a rollback banner, the daemon booted from an LKG config, or you want to know when to set watchdog.lkg_rollback_enabled = false (during rapid debug restarts). All three mechanisms are on by default with sensible bounds.

Quick setup. Leave everything at defaults:

[watchdog]
lkg_enabled = true
lkg_rollback_enabled = true
stability_window = "5m"
dormantctl status    # reports rollback state and running config fingerprint

dormant combines three recovery mechanisms:

  1. a health-gated last-known-good (LKG) config snapshot;
  2. boot-time rollback for invalid configs and counted crash loops;
  3. a systemd watchdog that restarts a process whose engine stops responding.

None of them changes the running rule engine in place. Rollback chooses the config for a new process; the watchdog asks systemd to replace a wedged one.

Last-known-good promotion

With watchdog.lkg_enabled = true, the daemon promotes the running config to $XDG_STATE_HOME/dormant/last-known-good.toml after watchdog.stability_window (default 5m) when all of these remain true:

  • every engine-liveness probe succeeds;
  • no reload occurs during the window;
  • the config bytes on disk still match the installed config;
  • no configured display reports every controller unhealthy.

An uncommanded display has no health evidence and does not block promotion. Repeated display-health deferrals are capped; after three, the candidate is promoted with lkg_promoted_with_unhealthy_display rather than leaving the daemon with no LKG.

The LKG is separate from <config_dir>/backups/. The web UI writes those backups before each browser apply. The daemon writes the LKG only after a healthy stability window, regardless of whether the config arrived through the web UI, file watch, SIGHUP, IPC reload, or boot.

Boot-time rollback

Rollback has three forms:

  • Immediate rollback: the current config fails to build, its bytes differ from the LKG, and the LKG builds successfully. The daemon logs config_rollback_boot and starts from the LKG. If the LKG also fails, startup fails normally with startup_failed.
  • Counted rollback: the same config fingerprint crashes or wedges at least three times in a 6-minute window. The next boot selects the LKG and records an active rollback.
  • Sticky continuation: while the original config remains unchanged, later boots in that crash storm keep selecting the LKG and log config_rollback_continued. After the quiet window, the daemon gives the original config one retry and logs config_rollback_retry.

The original config file is never overwritten. dormantctl status and the web dashboard show the pending rollback state.

Recovery after a rollback boot

Fix the intended config, validate it, then either let the file watcher pick it up or reload explicitly:

dormantctl validate
dormantctl reload

The running daemon's file watcher follows the OPERATOR config path (the file you edit) even while it is booted from the LKG substitute, so saving the fix from an editor triggers the same recovery without running dormantctl reload at all. Either way this is a live, in-place reload — no restart, no dropped engine state.

A reload that validates and builds successfully while a rollback is active clears the pending-reload banner, logs config_rollback_recovered, and atomically clears the persisted crash-loop state (rollback_active back to false, rolled_back_from back to null in crash-loop.json). Confirm recovery with:

dormantctl status

The banner disappearing (and status no longer reporting a rollback) confirms the fix took effect. A reload that still fails validation changes nothing: the daemon keeps running from the LKG, the banner and crash-loop state stay exactly as they were, and no LKG candidate is armed from the still-broken bytes.

Restarting the daemon (systemctl --user restart dormant) still works and remains a safe fallback — for example if the file watcher isn't running for some other reason — but it is no longer the required recovery step.

Counted-rollback gate

Set watchdog.lkg_rollback_enabled = false while intentionally restarting the daemon rapidly during debugging. This disables the counted trigger for a new storm. It does not disable immediate rollback for an invalid config or cancel an already-active sticky rollback.

Emergency wake

If displays are dark while the daemon is unavailable:

dormantctl emergency-wake

The command tries daemon IPC first with a bounded timeout. If IPC fails, it loads the config and drives the display controllers directly.

systemd watchdog

The shipped unit uses Type=notify and WatchdogSec=150. dormantd sends READY=1 after startup and sends WATCHDOG=1 only after the engine answers a liveness probe through its control channel. A scheduled but wedged process therefore does not keep itself alive with superficial watchdog pings.

Reload sends watchdog pings at internal step boundaries so a slow controller chain is not killed during a healthy generation swap. If a probe fails, the daemon logs watchdog_probe_failed, withholds the ping, and resets any pending LKG stability window.

Without NOTIFY_SOCKET / WATCHDOG_USEC, the same engine probe runs every 30 seconds for LKG health accounting, but no systemd notification is sent.

Upgrade order

Install the new dormantd binary before reloading a unit that changes from Type=simple to Type=notify:

install -Dm755 target/release/dormantd ~/.local/bin/dormantd
systemctl --user daemon-reload
systemctl --user restart dormant

Reloading the new unit while an old binary is still installed makes systemd wait for a READY=1 message that binary cannot send.

Configuration

[watchdog]
lkg_enabled = true
lkg_rollback_enabled = true
stability_window = "5m"
KeyDefaultNotes
watchdog.lkg_enabledtrueEnable LKG promotion
watchdog.lkg_rollback_enabledtrueEnable counted crash-loop rollback
watchdog.stability_window"5m"Healthy runtime required before promotion; minimum 30s

WatchdogSec, crash-count thresholds, and the quiet window are fixed recovery mechanics rather than config policy.

State and privacy

Recovery state lives under $XDG_STATE_HOME/dormant (normally ~/.local/state/dormant):

FilePurpose
last-known-good.tomlProven-stable config snapshot
last-known-good.meta.jsonAdvisory fingerprint and promotion timestamp
crash-loop.jsonBounded crash/restart history
discount-<nonce>One-shot clean-exit marker used around startup races

Directories are mode 0700; files are mode 0600. The files stay local and contain no telemetry.

Web UI

What this gives you. Live state, blank/wake controls, config editor, panel-wear tracking, doctor diagnostics, and Samsung pairing — all on a loopback-only HTTP/WS bridge with a built-in SPA. No separate web server or static-file host is needed.

When to use it. You want to drive the daemon from a browser instead of a CLI, or change config without editing the TOML file. Not for remote access — the web server binds loopback-only by default and has no authentication layer.

Quick setup. Build with --features web-ui, set daemon.web_port, and open the bound address:

[daemon]
web_port = 8080
web_bind = "127.0.0.1"
dormantctl status   # confirms the daemon is running with the web UI enabled

dormant serves an optional web dashboard for live state, controls, config editing, panel-wear tracking, failure state, and doctor reports. The SPA is embedded in dormantd; it needs no separate static-file server.

Enabling

The web UI is gated behind the Cargo feature web-ui:

cargo build --release --features web-ui

When the feature is enabled, these daemon config keys control the web server:

KeyDefaultDescription
daemon.web_port(unset)TCP port. Set to a port number to enable the web UI; unset (the default) leaves it disabled.
daemon.web_bind"127.0.0.1"Bind address — 127.0.0.1 or 0.0.0.0
daemon.web_allow_nonloopbackfalseRequire explicit opt-in before binding to a non-loopback address
daemon.entity_crud_enabledtrueAllow creating/deleting sensors, zones, displays, and rules from the Settings form (Entity create/delete)
daemon.pairing_enabledtrueAllow the Samsung pairing wizard (Pairing wizard)
daemon.pair_timeout"120s"How long the pairing wizard waits for the TV to accept before giving up (30s..300s)
daemon.hook_edit_enabledfalseAllow editing hook slots (on_blank, on_wake, on_observed_loss) in the Settings form. When false hooks are always read-only.

Example:

[daemon]
web_port = 8080
web_bind = "127.0.0.1"

If daemon.web_port is not set (the default), no HTTP server starts — even when the binary was compiled with the web-ui feature. The keys live under [daemon], not a separate [web] section.

Security posture

The web UI is for single-user, loopback-only use. It has no authentication, user sessions, or TLS. Run it on the same machine as dormantd and open it from that host.

Loopback guard

By default the server binds to 127.0.0.1 only. Setting daemon.web_bind = "0.0.0.0" (or any non-loopback address) is rejected at startup unless daemon.web_allow_nonloopback = true. The daemon logs a prominent warning when this override is active, because it makes the dashboard reachable from the local network without authentication — anyone on the LAN could issue blank/wake commands or change zone config.

Host guard

The server validates the Host header on every request. Requests with a Host that does not match the configured bind address are rejected with 403 Forbidden (the daemon logs a web_reject_host event). This blocks DNS-rebinding attacks even when bound to loopback only.

Origin (CSRF) guard

Every POST must carry Content-Type: application/json. Beyond that, two different Origin checks apply depending on the route. Most write routes (POST /api/blank, POST /api/wake, POST /api/switch, POST /api/push, POST /api/pause, POST /api/resume, POST /api/reload, POST /api/doctor) accept a same-origin request or one with no Origin header at all — a deliberately narrower posture, acceptable for routes that toggle running state rather than write config or credentials. Two routes carry a strict check instead: POST /api/config/apply and POST /api/pair/samsung. On these, the Origin header must be present and must match the bound loopback address and port exactly; an absent Origin is rejected. This closes the classic form-POST/no-Origin CSRF gap for the two routes that write to disk. A residual, honestly stated: a browser extension holding a loopback host permission isn't necessarily bound by page-Origin rules the same way ordinary page script is — a known gap, not claimed defended.

What this defends, and what it doesn't

The Host and Origin guards defend against DNS rebinding and cross-origin browser CSRF (a malicious page open in the same browser as the dashboard) — the realistic threat for a loopback tool with no login. They do not defend against another local process running as the same OS user: anything that can already execute code as the operator can forge Host/Origin headers and drive every route, including entity creation/deletion and the pairing wizard. This was never in scope for a single-user loopback tool — if an attacker already has code execution as the operator, the config and credentials are theirs regardless of any header check.

A config that passes validation but is functionally unwise (contradictory inhibitors, a rule that can never fire, an absurd grace period) is a misconfiguration, not a security hole. Validation checks that a config is structurally and referentially correct, never whether it embodies a sane blanking policy — entity CRUD is the first web surface that lets an operator author such a config from a browser, but the boundary this UI holds is "no invalid or dangling config reaches the running daemon," not "no unwise-but-valid config."

No upstream exposure

Do not reverse-proxy the dashboard onto the public internet. If remote visibility is needed, use an SSH tunnel (ssh -L 9100:localhost:9100 user@host) or a VPN. The dashboard was not designed for hostile-network exposure.

Views

The SPA has five views — plus a sixth (Switching) when shared displays are configured — selected from a left-hand navigation sidebar.

Overview

The dormant overview: stat row, signal-flow grid, and panel-exposure summary

The landing page. Shows a stat row (displays count with active/blanked split, sensor online/unavailable split, zone occupied/vacant split, Protected count), a three-column signal-flow grid (Sensors → Zones → Displays), a Rules column, and a recent-activity feed.

Each sensor row shows its id, type, state, and last-seen age. An unavailable sensor that has not delivered data since daemon start is marked "no data since start" from its reported diagnostic. Zone rows show occupancy, fusion mode, and members. Display rows show phase, blank mode, controller chain, and blank/wake controls. Failing displays appear in a dashboard banner; tracked displays also get a panel-exposure card.

Displays

A per-display card list. Each card shows a screen preview glyph (ON / grace / … / OFF / wake), phase and paused/inhibited status chips, the blank mode label, the driving zone and rule, the command generation counter, and the controller chain rendered as HealthChips (each controller's name, role — primary/fallback — and health status). Action buttons let the operator force-blank, force-wake, and pause or resume the governing rule. Shared displays additionally show Switch to here (PULL — writes the local input code via POST /api/switch) and Send to peer (PUSH — writes the peer's code via POST /api/push; visible only when shared_peer_input_write_code is configured). Both routes go through the same-origin weak-origin check.

Events

A scrolling, auto-pruning event log. It shows presence changes, display phase transitions, wake retries, blank/wake failures and recoveries, panel-wear advisories, and config reloads. Timestamps use the browser's clock when each WebSocket message arrives. Reloading the page seeds the log from /api/events/recent so recent history is preserved; events that arrive while the page is loading are deduplicated.

Switching

The switching view: three-region ownership panel, pull/push actions, and the hooks card

For displays configured with scope = "shared" and a [coordination] section, the Switching view shows live panel ownership — which machine currently holds the panel, the observed input code, and both machines' configured codes — updated in real time via Ownership daemon events over the WebSocket. Each display's row includes the local and peer input codes in hex, the panel state, short-press Poll button, and an agreement verdict that checks whether the observed code matches the configured local code. The view appears in the sidebar only when at least one display is configured as shared.

Display detail and panel exposure

The display detail view: control and health cards over the panel-exposure heat map

Select Open detail on any display card to inspect that display without leaving the Displays page. The detail surface combines live phase/controller health with the existing wear API:

  • total panel on-time and sample count;
  • average brightness-weighted hotness and current-grid uniformity;
  • the compensation advisory, when no long standby window has occurred;
  • a per-cell wear heat map from GET /api/wear/:display;
  • the configured controller chain in fallback order.

An empty heat map means the daemon has not recorded spatial samples for that display. It is not replaced with demonstration data.

Control-path exercise

The display detail and Doctor pages can run the daemon's real control-path exercise. The sequence is blank → read → wake → read → restore. The daemon pauses affected rules for the exercise window and always owns their release; the browser does not issue a separate resume request.

The selected panel will visibly go dark and return. Run the exercise only when the panel is not in use. The report distinguishes confirmed movement, unconfirmable controllers without readback, and failures where a command returned success but the observed panel state did not move.

A 504 is a report timeout, not an operation cancellation. The exercise may still be completing its restore and rule-release sequence; the browser keeps the action disabled until a GET /api/operations request started after the timeout reports that the display's server guard was released. A delayed response from a request started before the exercise cannot clear the warning. Display phase is not used for this decision because exercise drives the hardware directly. Do not retry while that warning is visible.

Emergency wake

Emergency wake is a global shell action for recovery when normal control has failed. After confirmation it pauses every rule and asks every configured display to wake concurrently. The response is { paused, displays }; each display result contains { display, ok, error? }. A 504 means the report did not arrive within two seconds and the wake may still be running — it does not mean the operation was cancelled. The global action stays disabled until a GET /api/operations request started after the timeout reports that the emergency-wake guard was released; delayed pre-operation responses are ignored. Emergency wake is global by design; there is no per-display variant.

Browser requests are single-flight, but issue #71 tracks the remaining daemon-wide limitations: CLI+web overlap, reload-generation races, engine-side exercise serialization, and panic-mid-exercise restore/resume hardening.

Rollback and failure banners

A rollback banner appears above all pages when boot guard rejected a candidate configuration and the daemon is running the last-known-good configuration. It shows the failed and active fingerprints and links to Config. The banner clears only after the recovered configuration has been accepted successfully.

Blank-chain exhaustion is also global. A separate failure banner identifies the affected display and controller evidence and links directly to its detail view.

Config

Six tabs: Daemon, Sensors, Zones, Rules, Displays, and Coordination — plus the original Raw TOML tab (a read-only syntax-highlighted viewer with inventory sidebar and a reload button).

Settings tabs

The Settings form presents the running config as editable sections, one per tab. Each field shows the current value from GET /api/config; edits are accumulated in a client-side patch store and submitted together via POST /api/config/apply.

What is editable:

  • Leaf string, number, and duration values across every section (e.g. grace_period, startup_holdoff, hold_time, coordination timeouts, keymap entries, input-filter patterns).
  • Whole arrays (e.g. a rule's displays list, the ladder array-of-tables, screensaver source lists, coordination keymap entries). Setting an array replaces it wholesale.
  • A limited set of optional keys can be removed via the Remove op: blank_mode, degraded_mode, dwell, order, image_duration, scale_mode, transition, transition_duration, hold_time, stale_timeout, ddc_display, output, wol_mac, host.
  • Hook slots (on_blank, on_wake, on_observed_loss) — editing is gated behind daemon.hook_edit_enabled = true (default: off); when disabled, hooks are displayed read-only.

File-only:

  • Display command strings (wake_command, blank_command) are not rendered in the Settings form.

What is not editable:

  • Locked leavestype on an existing entity, blank_data, and wake_data are never writable through the patch API. The form marks them locked and explains why. type is set only when an entity is created; changing a sensor type requires delete-and-recreate so type-specific fields cannot be dropped silently.
  • Credentials-redacted fields — URLs carrying inline userinfo (for example an MQTT broker_url with user:pass@) are redacted before the response is sent. The form locks the redacted path and its descendants.
  • General credentials editing — only the Samsung pairing token write (below) goes through the web UI. HA tokens, MQTT credentials, and any other credentials.toml content stay file-only.

Apply → reload flow:

  1. The form computes the patch delta from the user's dirty edits.
  2. POST /api/config/apply sends the current fingerprint plus the patches.
  3. The server re-reads the file, checks the fingerprint, validates and applies the patches, writes the new file, backs up the old file, and subscribes to the daemon's reload outcome.
  4. The daemon's config-file watcher detects the write, waits through the reload_debounce window, then reloads the runtime.
  5. The reload outcome is reported back in the apply response.

Outcome banners

The apply bar displays one of four outcomes after the request completes:

BannerMeaning
✓ ReloadedThe daemon accepted the new config and rebuilt the runtime successfully. The form clears its dirty state and re-fetches the new fingerprint.
✕ RejectedThe config was valid at write time but the daemon's reload failed (e.g. assembly error, removed-display verified-wake failure). A detail message names the cause. The old config is still running; the patched file is on disk.
pendingThe apply handler waited for the reload outcome but it did not arrive within the timeout (10 s). Normal when reload_debounce is large — the daemon coalesces the event and will reload shortly. The form re-fetches immediately; the file was already written.
supersededAnother writer (a second browser tab, dormantctl, or a direct file edit) landed after your apply wrote the file. The reload outcome belongs to their write, not yours.

Conflict dialog (409)

If the fingerprint in the apply request does not match the on-disk file (someone else edited the config between your last GET and your POST), the server returns 409 Conflict. The Settings form shows a red conflict dialog:

Config changed on disk — your edits are against an outdated version. Reload the form to get the latest config, or keep editing (your changes will be lost).

Reload form discards your edits, re-fetches the fresh config, and refreshes the form. Keep editing dismisses the dialog and leaves your dirty edits in place — you can then re-apply (the next attempt will use the current fingerprint, not the stale one).

Unsaved-changes guard

While the form has dirty edits, a beforeunload browser guard prevents accidental navigation away from the page. Switching from the Settings tab to the Raw TOML tab also triggers a confirmation dialog: "Discard N unsaved changes?". The guard is removed once all edits are applied or discarded.

Backups

Every POST /api/config/apply that succeeds (the file is written and fsync'd) creates a backup of the previous config file before the atomic rename. Backups are stored in <config-dir>/backups/ with names derived from the current UTC time plus a random 4-hex-digit suffix:

config.toml.2026-07-07T14:22:03Z.a3f1

The directory is created with mode 0o700 (owner-only). A rotation policy keeps at most 5 newest backups (sorted by filename, which encodes an RFC 3339 timestamp); older files are deleted after each new backup.

The config-file watcher uses RecursiveMode::NonRecursive on the config directory — writes inside backups/ do not trigger a reload.

The Raw TOML tab also documents the atomic-apply backup policy: the last five backups are retained under backups/config.toml.<timestamp>.<suffix>. The Web API does not expose the live backup directory listing, so the UI shows the policy and naming shape rather than fabricated filenames.

Entity create/delete

Each of the four Settings sections — Sensors, Zones, Displays, Rules — has an Add button that opens a creation form, and each entity card has a Delete button. Both ride the same apply flow as every other edit (fingerprint check, backup, atomic rename, reload-wait); there is no separate create/delete pipeline to reason about.

Creation and deletion use dedicated create_entity and delete_entity patch operations. Ordinary Set operations cannot create or replace a whole entity table, and they cannot change type on an existing entity.

Entity ids. An id must be 1–64 characters, start with a lowercase ASCII letter, and contain only [a-z0-9_-] afterward. A fixed set of names is reserved and can never be used as an entity id, because the server internals special-case them for unrelated reasons (as an array-of-tables key, a removable-leaf name, a locked-leaf name, or the config-key literal weights): type, blank_data, wake_data, source, ladder, weights, blank_mode, degraded_mode, dwell, order, image_duration, scale_mode, transition, transition_duration, hold_time, stale_timeout, ddc_display, output, wol_mac, host. The id field gives live feedback as you type; the server re-validates independently and is the real boundary. An id that collides with an existing entity in the same collection is rejected.

What each collection accepts at creation. The create form only exposes fields from a fixed, closed list per collection — there is no free-form "add any key" path:

  • Sensors — a type discriminator (mqtt, ha, or usb-ld2410, selectable only at creation — never changeable afterward) plus the fields for the chosen type, kind, hold_time, stale_timeout.
  • Zonesmode, members, unavailable_policy, weights.
  • Displayscontrollers, host, blank_mode, output, ddc_display, wol_mac, samsung_restore_backlight, restore_brightness, treat_unreachable_as_blanked, command_timeout. wake_command/blank_command are deliberately not offered here (or anywhere in the create path) — they are daemon-executed shell commands, and a web-created display cannot carry an arbitrary shell command in v1. A display that needs a command controller is still authored by hand in config.toml, exactly as before this feature.
  • Ruleszone, displays, grace_period, inhibitors, and the timing tunables.

Cross-references. Now that entities can be minted from the browser, the rule fields that name other entities — zone, displays, members, inhibitors — are editable dropdowns/multi-selects populated from the live inventory, instead of the read-only fields they were before this feature.

Delete. Deleting shows a confirmation naming anything in the currently-loaded config that references the entity (a client-side, best-effort check — it only knows what's in the form's inventory). The real reference-integrity guarantee is server-side: if a delete would orphan a reference (a rule pointing at a deleted zone or display, a zone listing a deleted sensor), the daemon-identical validation step at apply time rejects the whole request with 422 and the on-disk config is untouched. There's no separate reference-checking code path in the web layer to drift from the daemon's own rules.

The entity_crud_enabled flag. Set daemon.entity_crud_enabled = false to turn this off; the UI hides the Add/Delete affordances, but the server is the actual boundary — a create_entity/delete_entity patch sent while the flag is off is rejected with 403 feature_disabled regardless of what the UI shows. Ordinary Set/Remove edits are unaffected by this flag.

Pairing wizard

The Settings tab includes a Pair a Samsung TV card that walks through the network-pairing handshake dormantctl pair samsung performs from the command line, without leaving the browser.

  1. Enter the TV's hostname or IP address and click Pair. The server responds immediately (it never blocks the request on the TV) with an opaque pair_id, and the browser starts polling GET /api/pair/samsung/<pair_id> about once a second.
  2. While the attempt is in flight, the wizard shows "Connecting… accept the prompt on your TV" — the daemon can't distinguish "still connecting" from "waiting for you to press Allow" (both are the same blocking network call), so this message covers both.
  3. Accept the "Allow dormant" prompt on the TV when it appears. If you don't, the attempt eventually times out (daemon.pair_timeout, default 120s, configurable 30s300s) and the wizard shows a timeout message with a Try again button — pairing is not automatically retried.
  4. On success, the granted token is written to credentials.toml (mode 0600, atomic temp-file-plus-rename, same code path dormantctl pair uses) and the wizard offers to pre-fill a new display entity for that host (controllers: ["samsung-tizen"]) using the entity create form above. Declining still leaves the token stored — an unused Samsung token in credentials is harmless.

What never leaves the daemon: the pairing token is never present in any HTTP response body, and never appears in a log line — the code represents it with a type whose Debug/Display implementations redact to ***, so even an accidental log format string can't leak it.

Concurrency. Only one pairing attempt runs at a time (server-wide). A second POST /api/pair/samsung while one is already in flight gets an immediate 409 pairing_in_progress — it never queues behind the first attempt or blocks waiting for it.

The pairing_enabled flag. Set daemon.pairing_enabled = false to turn this off; the UI hides the wizard, and the server independently rejects POST /api/pair/samsung with 403 feature_disabled if called anyway.

Security notes specific to this route. POST /api/pair/samsung carries the same strict Origin check as /api/config/apply (see Origin (CSRF) guard above) — without it, a malicious page open in the operator's browser could trigger a pairing attempt against an attacker-chosen host. Its request body is capped at 4 KiB (a hostname is tiny; there's no reason to accept more). None of this changes the underlying trust assumption: pairing connects to whatever host the operator types, with TLS certificate verification disabled (the token is what authenticates the connection, not the certificate — the same posture the CLI pairing path and the ongoing Samsung control connection already use). Pointing the wizard at the wrong device is an operator mistake, not something the wizard can detect.

Doctor

Runs the same diagnostic checks as dormantctl doctor on demand. The SPA calls POST /api/doctor, which invokes the shared DoctorService directly (the same service instance the daemon's IPC server uses — no subprocess is spawned). Results include a summary bar (passing / skipped / failing counts) and per-check rows with status chips and detail messages.

The web doctor view does not run the destructive control-path exercise. Use dormantctl doctor exercise <display> when you need to prove that a real panel blanked and woke.

Audio-aware blanking

The Settings form renders the [audio] section, and rule editors accept "audio-playback" and "call" inhibitors. A rule that declares either kind spawns a PipeWire poller (pw-dump every [audio].poll_interval): while a matching stream is playing, the rule is inhibited and its displays stay awake even when the room reads vacant. Deassertion is immediate when playback stops. A rule that declares no audio inhibitor spawns no poller. See Configuration → [audio] for the classification knobs (call_roles, playback_roles, capture_is_call, min_active).

MQTT state publishing

Status: opt-in (issue #105). Off by default. The daemon never opens a broker connection on the publish plane unless [publish] enabled = true.

The publisher task forwards every retained state change and Home Assistant MQTT discovery record to a broker of your choice. The contract, sanitizer, and discovery payload shapes are pinned in .opencode/decisions/2026-07-27-mqtt-publish-contract.md; the daemon implements the same contract in crates/dormantd/src/state_publisher.rs.

At a glance

  • Single task per generation; reborn on every accepted reload — at most one publisher client_id connected at a time.
  • Republishes the full discovery + current snapshot on every connect/reconnect. Broker restart loses nothing.
  • QoS 1, retain: true for every state, availability, and discovery payload.
  • Home Assistant MQTT discovery is fully wired — entities appear in HA's MQTT integration as soon as the broker reports the discovery topics.
  • Graceful teardown publishes a retained offline on the global availability topic; the LWT handles ungraceful loss with the same payload.

Configuration

The [publish] section in your config.toml:

[publish]
enabled        = true
broker_url     = "tcp://homeassistant.local:1883"
base_topic     = "dormant"        # default
discovery_prefix = "homeassistant" # default
instance_id    = "office-pc"      # default: $HOSTNAME

enabled is the kill-switch. When false, the daemon does not spawn a publisher task, makes no MQTT connections on the publish plane, and emits no discovery records.

broker_url accepts any form dormant_core::mqtt::parse_broker_url parses — tcp://host:1883, mqtt://host:1883, or host:1883. The URL is also the exact credential-lookup key (matching the MQTT sensor convention in crates/dormant-sensors/src/mqtt.rs).

base_topic is the prefix every state topic is published under. The default dormant puts sensor state at dormant/<instance>/sensor/<id>/state. MQTT wildcards (+ / #) are rejected at validation time so a typo can never silently match more topics than intended.

discovery_prefix is the HA discovery prefix; the default homeassistant matches Home Assistant's MQTT integration settings. Change it only when your HA instance is configured to use a different discovery prefix.

instance_id is sanitized via the [sanitize_topic_id][crate::state_publisher::sanitize_topic_id] helper — every character outside [a-z0-9_-] becomes _, runs are collapsed, and empty results fall back to unnamed (a deliberate collision-warning signal). The raw value is preserved in the config so operators can see their actual id in the web inventory and logs. When unset, instance_id defaults to $HOSTNAME with a deterministic dormant fallback.

Credentials

Credentials live in the separate credentials.toml (the same file the sensors use). Match the publish broker_url exactly:

[ha_token]
# dormant: optional HA long-lived token (not needed by the publisher)

[mqtt."tcp://homeassistant.local:1883"]
username = "dormant"
password = "..."

A missing mqtt.<url> entry allows an anonymous connect — same as the sensors. dormant never publishes secrets; the password never appears in any log line, traced span, or stringified form of the transport surface.

Topics

With the defaults, instance_id = "office-pc", sensor id desk, zone id office, display id main:

TopicQoSRetainPayload
dormant/office-pc/availability1yesonline / offline (LWT + graceful cancel)
dormant/office-pc/sensor/desk/state1yesON / OFF
dormant/office-pc/sensor/desk/availability1yesonline / offline (per-sensor broker presence)
dormant/office-pc/zone/office/state1yesON / OFF (zone resolved presence)
dormant/office-pc/display/main/phase1yesJSON {"phase":"active"} (see below)
homeassistant/binary_sensor/office-pc/sensor_desk/config1yesHA discovery (binary_sensor, see below)
homeassistant/binary_sensor/office-pc/zone_office/config1yesHA discovery
homeassistant/sensor/office-pc/display_main/config1yesHA discovery (sensor)

The display/.../phase payload is a JSON object so HA's value_template: "{{ value_json.phase }}" parses the literal phase straight out of the published topic. Available literals: active | grace | blanking | blanked | waking | staged.

Each HA discovery config declares availability with the global LWT topic (dormant/<instance>/availability). Sensor entities additionally reference their per-sensor availability topic with availability_mode: "all" so HA marks a sensor unavailable the moment its source goes offline, even while the global topic is still online.

Collisions

If two distinct raw ids sanitize to the same topic id, the publisher emits a single publish_id_collision WARN per colliding pair and publishes only the first in config order — never interleaves two entities on one topic. The collision report also surfaces in dormantctl validate output so an operator can rename the offending id before deploy.

Empty / whitespace ids both sanitize to unnamed and the collision rule fires for them — a deliberate signal, not a free pass to the dormant default.

Reload behavior

A [publish] section edit (or any reload the validator accepts) tears the publisher down and re-spawns it with the new config. The old task is cancelled and awaited (producer_token.cancel(), bounded 5s grace) so at most one client id is connected at a time and the LWT fires offline for the old instance before the new instance starts. This means a config typo in [publish] does not brick the daemon — the previous generation stays online until the new generation fails its daemon-side validation.

What is NOT published

  • No secrets. The password never appears in any log, span, or stringified form of any public type.
  • No operational events. Blank failures, wake retries, wear samples, ownership transitions, and compensation advisories all have their own diagnostic surfaces (logs, IPC, web UI). The publisher carries state, never operational noise.
  • No DaemonEvent::Unknown. The wire-tolerance catch-all variant publishes nothing — the publisher never speculates about events the engine did not emit.

Doctor probe — out of scope

The dormant-doctor MQTT probe subscribes to a single sensor's topic and reports broker presence as part of the coalesced dormant doctor output. That probe is the read-side diagnostic and is unaffected by the publisher; this page is the write-side. Operators can verify the publisher is healthy by running dormant doctor and confirming the broker probe still reports the expected presence on the configured broker_url.

Troubleshooting

Doctor command

Start with dormantctl doctor. It checks your config and live state.

# Full system check
dormantctl doctor

# Per-component checks
dormantctl doctor mqtt      # probe MQTT sensors
dormantctl doctor ha        # probe HA WebSocket sensors
dormantctl doctor usb /dev/ttyUSB0  # probe USB LD2410
dormantctl doctor ddcci     # probe DDC/CI displays (Linux and macOS)
dormantctl doctor config    # validate configuration

# macOS-only, read-only checks
dormantctl doctor macos-idle            # two bounded raw idle-clock readings
dormantctl doctor macos-display-sleep   # display-sleep API availability + per-display state
dormantctl doctor macos-power           # active power assertions blocking display sleep

Each check reports status: OK, WARN, or FAIL. Warnings are non-fatal (e.g., a controller reports its last known state is stale). Failures indicate something needs fixing. The three macos-* checks exit 3 ("not yet supported") on Linux — they are read-only probes, never blanking or waking a display.

Doctor-assisted issue drafting

When something fails and you want to file it later without hand-reconstructing the context, --report-issue and --draft-feature run the full offline probe set (same as bare dormantctl doctor) and write a ready-to-paste draft to a file. The probe table and the final draft written to ... message still go to stdout:

# Bug report — pre-filled with version, environment, display inventory, and
# the probe table. Default path: ./dormant-issue-<YYYY-MM-DD>.md
dormantctl doctor --report-issue

# Feature request — same environment capture, no probe-failure framing.
# Default path: ./dormant-feature-<YYYY-MM-DD>.md
dormantctl doctor --draft-feature

# Explicit path
dormantctl doctor --report-issue /tmp/my-bug.md

Both flags mirror the field order and headings of .github/ISSUE_TEMPLATE/{bug,feature}.yml, so pasting the draft into a new GitHub issue lines up with the template. Machine-collectable fields (dormant version, OS/kernel, session type, compositor, the display inventory, config load status, and the probe table) are pre-filled; everything else is left as an <!-- fill in --> placeholder. Never overwrites an existing file — a name collision gets a -2, -3, … suffix.

Redaction is layered, not just allowlisting: display entries only ever carry id, panel type, controller list, and blank mode (never a host, token, or MAC address). On top of that, every literal secret value the loaded config and credentials actually hold — broker URLs, hosts, MAC addresses, HA/Samsung/MQTT credentials — is substituted with [redacted] across the entire draft, including probe result text, since a probe's own diagnostic detail can otherwise echo a host or broker URL verbatim (e.g. an mqtt auth-failure message). A second, cheaper pass scrubs any leftover bare IPv4 literal as defense-in-depth. Still, glance at the file before posting it publicly — redaction only catches values dormant itself knows about, not anything you type into a <!-- fill in --> section by hand.

The two flags are mutually exclusive with each other and with any doctor subcommand (ddcci, mqtt, …) — the draft always runs the full offline set. Pass the path with = (--report-issue=./ddcci) if you want a file genuinely named after a subcommand; the space form (--report-issue ddcci) is rejected because clap can't tell "PATH" from "subcommand" there.

Common errors

CodeMeaningFix
E_CONFIG_INVALIDConfig file syntax or structure errorCheck TOML syntax (dormantctl validate shows exactly where). Missing config_version key, wrong type for a value, invalid duration format.
E_CONFIG_UNKNOWN_KEYA key in the config is not recognizedRemove the key, or check for typos. Use --strictness=warn to see all unknown keys as warnings instead of errors.
E_ZONE_CYCLEZone references create a dependency cycleZone A cannot reference Zone B if B references A. Flatten nested zone chains.
E_ZONE_UNKNOWN_MEMBERZone references a sensor or zone that does not existCheck the member name. Sensor IDs must match [sensors.<id>]. Zone references must use "zone:<id>" syntax.
E_CREDS_PERMSCredentials file has incorrect permissionschmod 600 ~/.config/dormant/credentials.toml
E_CREDS_MISSINGRequired credential is missingThe config references a credential (e.g., HA token) but the credentials file does not contain it.
E_MODE_UNSUPPORTEDDisplay controller does not support the configured blank modeRun dormantctl doctor ddcci to see supported modes. Choose a different mode or add a fallback chain.
E_BLANK_FAILEDBlank command failedCheck the controller's logs. For DDC/CI: is /dev/i2c-* accessible? For command: does the shell command work when run manually?
E_WAKE_FAILEDWake command failedSame checks as blank. Verify the wake command works standalone. Check the wake_retries count.
E_RELOAD_WAKE_FAILEDDefensive wake on config reload failedA display was physically blanked before reload; the wake attempt to restore it failed. Check the controller and increase wake_retries.
E_HA_AUTHHome Assistant authentication failedVerify the ha_token in credentials file. Check that the token is still valid in HA.
E_SENSOR_IOSensor I/O errorMQTT: broker reachable? USB: port exists and has correct permissions? HA: WebSocket URL reachable?
E_DISPLAY_IODisplay controller I/O errorNetwork controller: host reachable? DDC/CI: I2C bus accessible? Command: binary exists?
E_IPCInter-process communication errorCheck that dormantd is running. Verify the socket path matches between daemon and CLI.

Display will not blank

Sensor is stale

Symptom: the rule stays active after the room empties.

Cause: a sensor stopped reporting. The zone treats unavailable as present by default, so dormant will not blank a room it cannot see.

Check with:

dormantctl status

Look for unavailable sensor states, then repair the broker, connection, or device.

Grace period has not elapsed

Symptom: the zone is vacant, but blanking is delayed.

Cause: rules.<id>.grace_period is still counting down. The default is 60 seconds.

[rules.office_blank]
grace_period = "30s"

Input-wake hold is active

Symptom: the display wakes on keyboard input but stays awake for longer than the configured grace period even though the zone is vacant.

Cause: rules.<id>.input_wake_hold is deferring the re-blank after a render-surface input wake (issue #125). The default is 120 seconds. Set to "0s" to disable and restore the immediate-grace behaviour.

[rules.office_blank]
input_wake_hold = "30s"

Inhibitor is active

Symptom: the sensor and zone are vacant, but the rule remains inhibited.

Cause: "user-activity" is active, or the rule was paused manually.

dormantctl status

Resume manual pauses with:

dormantctl resume

"audio-playback" and "call" are backed by the PipeWire poller in the [audio] section, which is opt-in and off by default. If a rule declares either kind but never holds a blank during playback, check that audio.enabled is true — see Configuration.

Min-wake-time floor

Symptom: a just-woken display stays on despite vacancy.

Cause: rules.<id>.min_wake_time has not elapsed. The default is 10 seconds.

dormantctl status

Emergency wake

If a display stays blank and a presence-mapped keyboard shortcut does not help — for example, no sensor is in the room or someone manually blanked the panel — run dormantctl emergency-wake. It force-wakes every configured display regardless of the rules engine's state.

dormantctl emergency-wake

The command tries the daemon's IPC first (2-second timeout). If the daemon is healthy, it pauses every rule indefinitely alongside the wake so nothing re-blanks until you run dormantctl resume. If the daemon is wedged or unreachable (the very failure mode this command exists for), dormantctl falls back to constructing display controllers directly from your config.toml + credentials.toml and sending the wake command itself — best-effort, one attempt per display.

For a one-keystroke recovery, bind the command to a global shortcut:

  • KDE Plasma: System Settings → Shortcuts → Custom Shortcuts → Edit → New → Global Shortcut → Command. Command: dormantctl emergency-wake. Trigger: your key.
  • GNOME: Settings → Keyboard → View and Customise Shortcuts → Custom Shortcuts. Command: dormantctl emergency-wake.
  • Sway / wlroots: bind in ~/.config/sway/config: bindsym $mod+grave exec dormantctl emergency-wake.

First-class daemon-registered shortcuts (no compositor setup) are a separate roadmap item.

macOS: gamma emergency restore

macos-gamma-black (see Displays) blanks by writing an all-black Quartz gamma table — there is no periodic reassertion and no restore-on-drop, so a dead daemon does not strand the panel any more black than it already is, but it also does nothing to fix it. dormantctl emergency-wake restores gamma through a dedicated, daemon-independent path:

  • Before writing a black table, dormant records a breadcrumb file ($XDG_STATE_HOME/dormant/gamma-blank.json or the ~/.local/state fallback) listing every display currently gamma-blanked, cleared again after a confirmed wake.
  • emergency-wake restores from that breadcrumb via CGDisplayRestoreColorSyncSettings() — a system-wide restore, not a per-panel replay of the exact pre-blank table — and does this independently of whether the daemon's IPC call succeeds, fails, or times out. It also runs on the next dormantd startup if a stale breadcrumb is found, so a crash never leaves gamma permanently black either.
  • Wedged-daemon caveat: if emergency-wake's IPC call times out (daemon alive but hung, not merely unreachable) rather than being refused, the command prints a warning that the daemon may still complete a queued blank write after the fallback restore already fixed the display — re-blanking it. If you see the display go black again shortly after an emergency wake, stop dormantd (or launchctl kickstart -k / systemctl --user restart dormant it) before rerunning emergency-wake.

Control-path verification — dormantctl doctor exercise <display>

Confirms that a blank/wake actually moved the panel — the systemic guard against the failure mode where a controller logs blank_succeeded while the panel never changed (the samsung stale-socket + port-1516 400s both did exactly this).

dormantctl doctor exercise mon

The command routes through the daemon over IPC: the daemon pauses the target's rule(s) for the exercise window, runs blank → read → wake → read → restore on its live controllers, and replies with a per-step report. Exit code is non-zero only when at least one step verdict is Failed — a confirmable panel that did not move despite the controller returning Ok. The wake path is sacred: the restore step guarantees a final wake regardless of any earlier failure, so an exercise can never leave a display dark.

Verdicts

  • Confirmed (✓, green) — the panel state moved as expected (blank step: state changed from baseline; wake step: state returned to baseline).
  • Unconfirmable (~, yellow) — the controller has no readback for the panel. The command was issued but the panel could not be observed. Exit 0 (honest, not a fake pass).
  • Failed (✗, red) — the controller can read the panel but the state did NOT move as expected. Exit non-zero.

Confirmability by controller

ControllerConfirms via
ddcciVCP 0x10 (brightness, 0–100) + VCP 0xD6 (power: 0x01 → On, otherwise → Standby)
samsung-tizenREST PowerState ("on" / "standby"); port-1516 backlight when configured for BrightnessZero
command / kwin-dpms / ha-passthroughnone — every step is Unconfirmable

Operational notes

  • The exercise causes a brief blank → wake blink on a display currently in use (the wake step + the defensive restore wake).
  • The command is per-display only (--exercise <name>); there is no --all or default target.
  • command / kwin-dpms / ha-passthrough controllers report Unconfirmable for every step because they cannot observe the panel — exit 0, but no verification was possible.

Failure notification does not appear

Symptom: the tray or web dashboard marks a display failed, but no desktop notification appears.

Cause: wake failures notify only after notifications.wake_attempt_threshold consecutive attempts; repeats are limited by notifications.cooldown. Desktop notices may also be disabled.

journalctl --user -u dormant -f

Check notifications.enabled, the session D-Bus, and notify_suppressed / notify_unreachable events. Blank failures notify on the first exhausted controller chain. See Failure notifications.

Daemon booted from last-known-good config

Symptom: dormantctl status or the web UI says the current config was rolled back, and edits to the original config do not take effect after reload.

Cause: the daemon booted from last-known-good.toml because the operator config failed validation during a crash loop.

Fix the intended config, then reload — the daemon watches the operator config even after a rollback boot, so the watcher picks the fix up on save (or run dormantctl reload explicitly):

dormantctl validate
dormantctl reload

A successful reload of the fixed config clears the rollback state and the banner (config_rollback_recovered in the journal). A service restart also works, as the fallback path. See Watchdog + last-known-good rollback.

Logging

Set log_level = "debug" in the daemon config for detailed logs:

[daemon]
log_level = "debug"

Key log events to search for:

  • sensor_event — each presence update with source, value, and timestamp
  • zone_transition — when a zone changes between occupied and vacant
  • rule_blank / rule_wake — when a rule fires a blank or wake command
  • wake_failed — wake attempt failures with controller and retry count
  • reload_complete — config reload finished
  • reload_defensive_wake — display woken because it was blanked before the reload

Logs are written to stderr by default. When running under systemd, view them with:

journalctl --user -u dormant -f