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

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.