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
dormantdrestart mid-blank does not itself flash the desktop — the in-memory captured table is simply gone, and awake()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 nextdormantdstartup, before any config is loaded, a leftover breadcrumb triggers a system-wideCGDisplayRestoreColorSyncSettings()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-wakefor that — it restores from the same breadcrumb independently of daemon health. See macOS: gamma emergency restore.
Recommended macOS chain
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 G2backlightControl→ 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():
- Idempotent — safe to call on an already-awake display.
- Retries or escalates — must not silently give up. Internally retry, or let the executor's chain handle it.
- 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:
| Controller | Mechanism | Audio-safe because |
|---|---|---|
ddcci | VCP 0xD6 (monitor-internal command over I2C) | Panel blanks internally; OS output stays active |
samsung-tizen | KEY_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:
- If the display has DDC/CI and supports VCP D6 → use
ddccipower_off. - If the display is a Samsung Tizen TV → use
samsung-tizenpicture-off. - If the display has no DDC/CI and no audio →
kwin-dpmsis fine. - If the display has audio but neither DDC/CI nor Tizen → use a
commandcontroller 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_offcontroller 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_blackpreserves 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:
- The host is a macOS machine running
dormantd. - The display is
scope = "shared"(multi-machine KVM switching). - The display's first controller is
ddcci(a ddcci later in the fallback chain does not trigger the hazard — the primary path matters). - The display's primary blank mode is
power_off(screen_off_audio_onandbrightness_zeroare 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
0x60writes (input switching) and VCP0xD6writes (power) succeed at the bus level but the panel ignores them — the link is gone. - A second
power_offwrite 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 1after recovery returns the panel toOn, 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:
-
Config load —
load_configemits a semantic warning at every load (bothStrictness::StrictandStrictness::Warnmodes):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 validateand the web config editor surfaces it through theApplypanel. -
Doctor —
dormantctl doctoremits aFailprobe namedmacos-power-off-hazardwith the display id assubjectand 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:
-
Soften the primary mode —
screen_off_audio_onorbrightness_zeroare audio-safe and do not drive a panel standby on the USB-C link. With macOSsamsung-tizenthey reach the panel over the network, not the USB hub. -
Lead with
macos-gamma-black— the gamma controller does not power-cycle the panel at all. Puttingmacos-gamma-blackfirst andddccisecond 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
ddccionly when the gamma controller cannot reach the panel. The hazard applies only whenddcciactually 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.