Crate map, data flow, and where-to-find-it guide for the dormant codebase.
| Crate | Purpose | Has binaries? |
|---|---|---|
dormant-core |
Domain types, traits, config schema/validation, MQTT publish configuration (schema.rs — PublishConfig), zone fusion engine, rules engine, state machine, IPC protocol, reload types, doctor wire types (DoctorReport/Check/CheckStatus), panel-wear ledger model (wear.rs — WearLedger/WearIdentity/PanelType/WearHandle/identity normalization; DaemonEvent::WearSnapshot + CompensationAdvisory), multi-machine ownership (ownership.rs — OwnershipGate; coordination.rs — daemon-lifetime shared-display observation state and contested verdict; observation.rs — content revisions and diagnostic observations), spatial wear attribution math (spatial_grid.rs — LumaGrid/HeatGrid/resample_area), display sampling configuration (DisplaySamplingConfig / compositor_output), and shared MQTT broker helper (mqtt.rs) — pure logic, no I/O; emits DaemonEvent::Ownership on switch outcomes |
No |
dormant-sensors |
Sensor sources: MQTT (mqtt.rs), Home Assistant WebSocket (ha_ws.rs), USB-serial LD2410 radar (usb_ld2410.rs), plus a shared backoff helper and a static registry |
No |
dormant-displays |
Display controllers: arbitrary shell command (command.rs), DDC/CI (ddcci.rs, Linux and macOS — the vendored ddc-macos fork backs RealVcp on macOS), KWin DPMS (kwin_dpms.rs, Linux-only), abstract VCP operations (vcp_ops.rs — ddc-hi real backend + scripted fake, spawn_blocking/catch_unwind panic recovery, per-ident handle cache with five-minute absolute revalidation and transport-vs-protocol invalidation, process-wide physical-DDC gate for enumeration/transactions/teardown), per-panel DDC/CI bus lock (ddc_lock.rs — PanelLocks registry, VcpPriority::Command vs Sampler, poison recovery), process-shared blank-owner registry (blank_owner.rs — BlankOwnerRegistry for owner-first wake survival across reloads), Home Assistant passthrough (ha_passthrough.rs), Samsung Tizen (samsung_tizen.rs — port 8002 WebSocket + Wake-on-LAN + pairing, with pair()'s connect step behind an injectable PairConnect trait — RealPairConnect for the real WSS handshake, test_support::FakePairConnect behind the test-util feature for deterministic pairing-route tests in dormant-web) plus its audio-safe IP Control G2 JSON-RPC transport (samsung_ip.rs — port 1516 backlightControl for brightness_zero + input-source query/readback), execution engine with fallback/retry (executor.rs), static registry. macOS-only (#[cfg(target_os = "macos")]) modules: macos_gamma_black.rs (audio-safe Quartz gamma-table black, cg:<uuid>-selector, first-blank-wins GammaHoldRegistry, no periodic reassertion/no Drop restore — see Displays), macos_display_catalog.rs (thin Quartz display resolution FFI backing macos_gamma_black.rs), gamma_breadcrumb.rs (daemon-lifetime + on-disk breadcrumb backing the emergency/startup restore path), macos_display_sleep.rs (global pmset displaysleepnow fallback + IOPM-assertion wake, no per-display selector), macos_power.rs (active display-sleep-preventing power assertions) |
No |
dormant-doctor |
Hardware/connectivity health checks: probes for config, MQTT, HA WebSocket, USB LD2410, DDC/CI (lock-backed brightness/power/input readback), Samsung Tizen (reachability + power state + token presence), evdev input filter readiness (input_filter.rs); live coalesced DoctorService for the daemon + web UI, including the live-only wear_sampling.rs probe; issue/feature report draft rendering (draft.rs); control-path verification — dormantctl doctor --exercise <display> routes an Exercise IPC request through the daemon, which pauses the target's rules and steps blank→read→wake→read on the live controller chain to confirm each command actually moved the panel. Wire types live in dormant_core::doctor to avoid a cycle. macOS-only, read-only probes (Task 11): macos-idle (bounded raw idle-clock readings), macos-display-sleep (API availability + per-display asleep/awake state), macos-display-catalog (online CoreGraphics UUID selectors), macos-power (active display-sleep-preventing power assertions), macos-power-off (macOS shared-DDC/CI power-off hazard probe, flagging power_off on shared USB-C displays unless opted in via power_off_opt_in = true) — never blank or wake a display |
No |
dormant-web |
Loopback-only web dashboard: axum HTTP/WS bridge that reads live engine state and serves a SPA (crates/dormant-web/webui/). Optional dependency of dormantd, gated behind the web-ui Cargo feature — when off, zero web code is compiled. config_patch.rs also gates entity create/delete (CreateEntity/DeleteEntity patch ops, a per-collection closed creatable-field allowlist, and a reserved-entity-id ban); routes/pair.rs is the Samsung pairing wizard and routes/wear_sampling.rs exposes daemon-owned sampling status/disable flow. |
No |
dormant-render |
Local Wayland layer-shell RenderSink: software-blank black overlay (final ladder fallback when every display controller failed) and libmpv-driven screensaver overlay (last-resort idle surface) with pixel-shift (shift_px/shift_interval keys on [displays.<id>.screensaver], ShiftSettings re-exported alongside ScreensaverSettings; pure raster-walk math in shift.rs — U5: the black overlay never shifts), screensaver luma grid caching (luma.rs — LumaCatalog/scan_luma_grid), and wear-even playlist ordering (wear_order.rs — wear_even_cycle/apply_wear_even_groups, deterministic score ordering). Wayland I/O is target_os = "linux"-gated; non-Linux builds expose a no-op stub with the same LayerShellRenderSink surface so callers compile unconditionally |
No |
dormantd |
Daemon binary: config loading, event loop, IPC server (including the daemon-wide ConsentGate that serializes active-sampling portal consent), inhibit-activity watcher + the PipeWire pw-dump-polling audio/call inhibitor (audio_source.rs/inhibit_audio.rs — poll/classify/debounce/circuit-breaker, mirrors idle_source.rs's publish/dedup discipline), single-instance flock, reload handling, local direct display switching (direct_switch.rs), local activity follow (activity_follow.rs), multi-machine display observation (coordination_poll.rs reads each shared display's local VCP 0x60 every poll tick and refreshes panel state at the slower state_poll_interval), and MQTT state publishing (state_publisher.rs — pure state-to-record mapping for HA discovery and state/availability publishing). It also owns the panel-wear tracker, the daemon-lifetime active sampler (active_sampler.rs + Linux active_sampler/linux.rs + source gate module active_sampler/source_gate.rs) and consent record (screencast_consent.rs), desktop failure notifier, boot guard, optional web UI spawn, logging, and macOS-specific idle/gamma recovery. |
Yes — dormantd |
dormantctl |
CLI binary: status, pause, resume, blank, wake, switch, reload, validate, watch, emergency-wake, doctor, wear (wear enable-sampling / wear disable-sampling, requiring --display when multiple displays configure sampling), and pair samsung <host>. It also provides macOS launchd install/launchd uninstall and re-exports its IPC client module as a library entry (crates/dormantctl/src/lib.rs) so dormant-tray can drive the same protocol without forking the socket glue |
Yes — dormantctl |
dormant-tray |
Tray applet: KDE StatusNotifierItem (ksni) on Linux and a native AppKit NSStatusItem backend on macOS (tray_macos.rs). Cross-platform pure logic (state, tooltip, menu, icon, dispatch, action_table, tray_state, template_icon) feeds the live icon (Normal / Attention / Paused / Failure / Unreachable), pause/resume/blank/wake menu items, tooltip, and the reconnecting IPC event-stream reader; menu_refresh.rs consumes its refresh channel so Linux calls ksni::Handle::update() when daemon state changes. Linux and macOS share the TrayState/ipc_loop machinery; only the platform-glue modules (tray.rs vs tray_macos.rs) are cfg-gated. Non-Linux/macOS bins print a notice and exit 1 so the portability cargo check stays green |
Yes — dormant-tray |
Each crate follows the convention: one module per concept, one file per sensor/controller, explicit static registry with no proc-macro magic.
Wear-even screensaver state crosses the daemon boundary through the wear
tracker's item journal. dormant-render/src/luma.rs owns cached host-side luma
grids, while wear_order.rs applies deterministic ordering at session install;
missing data fails open without blocking playback.
┌──────────────┐
MQTT ──────────▶│ │
HA WebSocket ──▶│ Sensors │──▶ PresenceEvent ──▶
USB LD2410 ────▶│ (registry) │ │
└──────────────┘ │
▼
┌──────────────┐ ┌──────────────────┐
Config ────────▶│ Zone Engine │──▶ Zone │ Rules Engine / │◀── User activity (idle_source.rs)
(schema.rs) │ (fusion) │ State │ State Machine │◀── Audio / call (audio_source.rs, pw-dump poll)
└──────────────┘ └──────┬───────────┘
│
Blank / Wake commands
│
▼
┌──────────────┐
│ Executor │──▶ Controller chain (fallback)
│ (retry, │ ├── kwin-dpms (Linux fallback, audio-unsafe)
│ escalation)│ ├── ddcci (Linux + macOS, audio-safe via VCP 0xD6)
└──────────────┘ ├── command
├── ha-passthrough
├── samsung-tizen (audio-safe)
├── macos-gamma-black (macOS, Quartz LUT, audio-safe)
└── macos-display-sleep (macOS last resort, audio-unsafe)
- Sensors produce
PresenceEventvalues (occupied / vacant) and push them to the zone engine. - The zone engine fuses events from multiple sensors per zone using the configured mode (
any,all,quorum,weighted). Unavailable sensors are treated as present (fail-safe — never blank a room you can't see). - The rules engine maps zone state to display commands, applying grace periods, min-blank/min-wake floors, and per-kind inhibitor checks: user activity (
idle_source.rs) and an opt-in PipeWire audio/call poller (audio_source.rs— audio playback and call detection viapw-dumppolling; a rule reacts only to the kinds it declares ininhibitors). Kinds OR together per rule (any declared kind active holds the blank). Manual pause (Overlays.paused, driven bydormantctl pause/resumeor the web UI) is a separate mechanism, not one of these inhibitor kinds. - The display executor walks an ordered controller chain per display: tries the first controller, falls back on failure, retries wakes with bounded backoff, and escalates to the next controller if all retries are exhausted.
- For
scope = "shared", the coordination poller reads local VCP0x60on everycoordination.poll_intervaltick before the executor acts; it refreshes brightness/power cosmetics only at the slower effectivecoordination.state_poll_intervalcadence (defaultmax(30s, poll_interval)). Panel input selection and state are observed locally via VCP readbacks; no network pairing or claim broadcasts are used. Repeated ownership transitions produce a contested verdict that forces not-owned until the settle interval passes.
Local direct-switch traffic — hotkey, CLI switch (dormantctl switch), tray, web UI, and local activity edges —
flows through the direct-switch service in dormantd/src/direct_switch.rs.
Hotkey / CLI / Activity edge / Tray / Web
│
▼
┌─────────────────┐
│ dormantctl / │
│ tray / web IPC │──── ▶ DirectSwitchHandle.pull / push
│ activity_follow │
└────────┬────────┘
│
▼
┌─────────────────┐
│ DirectSwitch │ action dispatch (dormantd)
│ Service │ ├─ RunBeforeRelease → hooks engine
│ │ ├─ WriteInput → DDC VCP 0x60 write
│ │ └─ RunAfterRelease / AfterAcquire → hooks
└────────┬────────┘
│
▼
┌─────────────────┐
│coordination_poll │ VCP 0x60 readback updates debounced input state
│(every poll_interval) │
└─────────────────┘
The DirectSwitchHandle in dormantd/src/direct_switch.rs performs local input switches over the machine's own DDC bus with no network protocol, peer transport, or frame signing. It handles hook execution (in-order slot, per-entry timeout, and aggregate displays.<id>.hooks.timeout ceiling) and DDC input-source writes (VcpPriority::Command), while coordination_poll periodically reads back VCP 0x60 to maintain debounced local vs peer observation state.
One physical input reader drives three consumers through a single watch channel:
┌──────────────────┐
│ InputAuthority │ evdev nodes (Linux) or CGEventTap (macOS)
│ Supervisor │ serialized stock→filtered transitions
└────────┬─────────┘
│ FilteredActivityTx (watch channel)
▼
┌───────┼───────────┬──────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌──────┐ ┌────────┐ ┌──────────────┐ ┌──────────────┐
│idle │ │activity│ │claim-edge │ │InputWake │
│src │ │inhibit │ │(edge/armed │ │validator │
│(stock│ │or │ │ policy) │ │(F8 ≤500ms │
│ cmpst│ │idle obs│ │ │ │ proof) │
└──────┘ └────────┘ └──────────────┘ └──────────────┘
The InputAuthoritySupervisor in dormantd/src/filtered_activity.rs is the
sole producer-authority arbiter: it serializes every stock↔filtered
transition, never grants two concurrent producer tokens, and publishes a
single FilteredActivity (edge sequence + last-activity timestamp) that feeds
all downstream consumers. The supervisor's stock fallback runs a compositor
idle-source while the filtered source is being probed, recovered, or has
failed — activity reporting never stops, it degrades from filtered (per-device)
to stock (per-seat) and back.
| Task | Where |
|---|---|
| Add a sensor | dormant-sensors/src/<name>.rs (new module) + dormant-sensors/src/registry.rs (register it) + dormant-core/src/config/schema.rs (config variant) |
| Add a display controller | dormant-displays/src/<name>.rs (new module) + dormant-displays/src/registry.rs (register it) + dormant-core/src/config/schema.rs (config fields if needed) |
| Add a config key | dormant-core/src/config/schema.rs (struct field + serde) + dormant-core/src/config/defaults.rs (default value) + dormant-core/src/config/validate.rs (validation rule AND the KNOWN_KEYS known-key-tree entry for unknown-key detection — mod.rs only calls validate::collect_unknown_keys, it does not hold the tree) |
| Add an error code | dormant-core/src/error.rs (pub const E_* + variant in DormantError) |
| Change timing defaults | dormant-core/src/config/defaults.rs (single source of truth) |
| Add a doctor probe | dormant-doctor/src/probes/<name>.rs (new probe) + dormant-doctor/src/probes/mod.rs + dormant-doctor/src/lib.rs (re-export) + dormantctl/src/cmd_doctor.rs (CLI dispatch + subcommand) |
| Wire doctor into the daemon | dormantd/src/app.rs (construct one DoctorService shared by IPC + web UI) + dormant-doctor/src/service.rs (coalesced singleflight logic) |
| Add a web route | dormant-web/src/routes/<name>.rs (new module) + dormant-web/src/routes/mod.rs (mount) + dormant-web/src/server.rs (router) |
| Add a CLI subcommand | dormantctl/src/cmd_<name>.rs (new file) + register variant + dispatch in dormantctl/src/main.rs. Short commands that share a handler (e.g. pause+resume, blank+wake, wear enable-sampling+disable-sampling) co-locate in one cmd_*.rs |
| Add a render ladder stage | dormant-render/src/<module>.rs (shared pure logic — command, latch, settings, playlist, luma for luma grid caching, wear_order for wear-even playlist ordering, shift for pixel-shift raster-walk math) + dormant-render/src/linux/<impl>.rs for the Wayland implementation (Linux-only). The cross-platform LayerShellRenderSink re-export is in dormant-render/src/lib.rs |
| Adjust screensaver wear-even playlist ordering or luma scanning | dormant-render/src/{luma,wear_order}.rs + dormant-core/src/spatial_grid.rs + dormantd/src/wear_tracker.rs |
| Adjust spatial grid / luma grid resampling math | dormant-core/src/spatial_grid.rs |
| Add a tray menu/state piece | dormant-tray/src/<module>.rs for cross-platform pure logic — state (icon-state derivation), tooltip (tooltip build), menu (menu model), icon (pixmap construction + runtime overlays), dispatch (DispatchPlan + DispatchCapabilities trait + SystemCapabilities impl, decouples action planning from platform I/O), action_table (tagged projection of menu actions for platform target/action callbacks — the bridge from the platform-neutral menu to ksni::StandardItem callbacks or AppKit NSMenuItem setTarget:/setAction:/setTag:), tray_state (TrayState shared by the IPC loop and the platform frontends), template_icon (pure monochrome renderer for the macOS template icon — black + source alpha, setTemplate(true) lets AppKit tint per appearance). Linux-specific glue lives in dormant-tray/src/tray.rs; macOS-specific glue lives in dormant-tray/src/tray_macos.rs (AppKit NSStatusItem + NSMenu + NSMenuItem with MenuTarget objc2 class that forwards performAction: to a tokio mpsc Action channel). dormant-tray/src/ipc_loop.rs is shared (Linux + macOS) |
| Drive the daemon from another binary | Reuse dormantctl::client (re-exported via crates/dormantctl/src/lib.rs) — dormant-tray is the canonical example |
| Adjust panel-wear tracking behavior | dormant-core/src/wear.rs (pure model — WearLedger/PanelType/sanitize_identity_key/brightness_norm) + dormant-core/src/config/schema.rs (the [wear] and [displays.<id>].panel_type TOML keys) + dormantd/src/wear_tracker.rs (pure tick + async shell — sampling cadence, ledger I/O, advisory latch) + dormant-web/src/routes/wear.rs (HTTP exposure — GET /api/wear, GET /api/wear/<display>) |
| Adjust active wear sampling | dormantd/src/active_sampler.rs + active_sampler/linux.rs + active_sampler/source_gate.rs (source-gated TV sampling, portal/PipeWire capture, consent binding to output position, portal stream position retention, generation-tagged warm worker retention across capture timeouts, and latest-grid flow) + dormantd/src/screencast_consent.rs (token rotation and daemon-owned consent) + dormant-core/src/config/schema.rs/defaults.rs ([wear.active_sampling], [displays.<id>].sampling, compositor_output) + dormant-doctor/src/probes/wear_sampling.rs + dormant-web/src/routes/wear_sampling.rs + dormantctl/src/cmd_wear.rs |
| Add a panel-bus readback / usage-hours seed | Add the per-controller contract to dormant-core/src/traits.rs (DisplayController::read_state/read_state_sampled/read_usage_hours/panel_identity/claim_identity), implement on the controller in dormant-displays/src/<name>.rs, surface the chain-walk on dormant-displays/src/executor.rs; serialized under the per-panel lock plus the process-wide physical-DDC gate; dormant-displays/src/vcp_ops.rs owns the cached-handle lifetime and transport-error invalidation, while dormant-displays/src/ddc_lock.rs enforces command priority |
| Adjust failure-notification behavior | dormantd/src/notifier.rs (pure decide/reconcile policy + NotifyState/ZbusSink) + dormant-core/src/config/schema.rs (the [notifications] section) + dormantd/src/reload.rs (zero_changed_displays/dispatch_relevant_eq — the reload-voiding gate) + dormant-tray/src/state.rs (Failure icon predicate) + dormant-web/webui/src/app/components/FailureBanner.tsx (dashboard banner) |
| Adjust watchdog / boot-rollback behavior | dormantd/src/boot_guard.rs (pure decide crash-loop verdict + should_promote LKG-promotion gate + the sync prepare I/O shell) + dormantd/src/boot.rs (boot() — build/substitute/lock/start, the one boot()-owned immediate-rollback write) + dormantd/src/sd_notify.rs (SdNotify, watchdog_interval_from_env) + dormantd/src/app.rs (watchdog_tick/lkg_tick — the probe arm, LKG candidate bookkeeping, reload step-boundary pings) + dormant-core/src/config/schema.rs (WatchdogConfig, the [watchdog] section) + crates/dormantd/systemd/app-dormant.service (Type=notify, WatchdogSec) + dormant-web/webui/src/app/config/WatchdogSection.tsx (web UI section) |
| Adjust multi-machine coordination | dormant-core/src/config/schema.rs (CoordinationConfig, [coordination]) + dormant-core/src/{coordination,ownership,observation}.rs (pure ownership observation state) + dormantd/src/{coordination_poll,direct_switch,activity_follow}.rs (local VCP 0x60 input polling at poll_interval, slower panel-state refresh at state_poll_interval, direct DDC input switches, local activity follow) + dormantctl/src/cmd_switch.rs (dormantctl switch) |
| Adjust entity CRUD (create/delete sensors/zones/displays/rules) | dormant-web/src/config_patch.rs (CreateEntity/DeleteEntity gates, CREATABLE_FIELDS per-collection allowlist, RESERVED_ENTITY_IDS, validate_entity_id) + dormant-web/src/routes/config_apply.rs (the entity_crud_enabled server-side gate, ahead of the shared apply pipeline) + dormant-web/webui/src/app/config/{CreateEntityForm,entityCrud}.ts(x) (client mirror + create form) + {Sensors,Zones,Displays,Rules}Section.tsx (Add/Delete affordances, cross-ref dropdowns) |
| Adjust the Samsung pairing wizard | dormant-web/src/routes/pair.rs (POST/GET /api/pair/samsung[/<id>], PairStatus/PairId/Token redaction, pair_lock single-flight, sweep_expired) + dormant-displays/src/samsung_tizen.rs (PairConnect trait, RealPairConnect, pair/pair_with_connect) + dormant-displays/src/test_support.rs (FakePairConnect, test-util feature) + dormant-core/src/config/mod.rs (upsert_samsung_token, 0600 atomic write) + dormant-web/webui/src/app/config/PairingWizard.tsx |
| Adjust MQTT state publishing | dormant-core/src/config/schema.rs (PublishConfig, [publish]) + dormant-core/src/config/defaults.rs + dormant-core/src/config/validate.rs + dormantd/src/state_publisher.rs (pure state-to-record mapping for HA discovery and state/availability publishing) |
Every log event name and error code is a literal string at the definition site — never format!-constructed, never macro-generated. This makes them reliably greppable:
- Error codes:
E_CONFIG_INVALID,E_CONFIG_UNKNOWN_KEY,E_ZONE_CYCLE,E_ZONE_UNKNOWN_MEMBER,E_CREDS_PERMS,E_CREDS_MISSING,E_MODE_UNSUPPORTED,E_BLANK_FAILED,E_WAKE_FAILED,E_RELOAD_WAKE_FAILED,E_HA_AUTH,E_SENSOR_IO,E_DISPLAY_IO,E_RENDER_UNAVAILABLE,E_SCREENSAVER_SOURCE,E_IPC,E_HOOK_FAILED,E_HOOK_TIMEOUT— all defined indormant-core/src/error.rs.- Log events: grep for
event = "..."in the source. Key events includedisplay_phase,sensor_source_exited,wake_failed,config_reloaded,reload_defensive_wake,coord_ownership_contested,coord_ownership_settled,coord_contested_readback_changed,wear_tracker_started/_resumed/_parked,wear_advisory,wear_ledger_corrupt,wear_ledger_unreadable,wear_ledger_future_version,wear_ledger_seeded,wear_persist_failed,wear_sample_fallback,wear_sampling_consent_target,notifier_started,notify_sent,notify_failed,notify_unreachable,notify_suppressed,notify_events_lagged,notify_close_failed(all defined indormantd/src/notifier.rs). Watchdog/rollback events:crash_loop_detected,config_rollback_boot,config_rollback_continued,config_rollback_retry,lkg_missing_rollback_disarmed(dormantd/src/main.rs+boot.rs),lkg_saved,lkg_save_failed,lkg_skipped_dirty,lkg_deferred_display_health,lkg_promoted_with_unhealthy_display,watchdog_probe_failed(dormantd/src/app.rs),sd_notify_unavailable(dormantd/src/sd_notify.rs). Gamma-recovery events:gamma_stale_breadcrumb_restored,gamma_breadcrumb_clear_failed,gamma_stale_breadcrumb_restore_failed(all indormantd/src/gamma_recovery.rs, with thecaller = "startup"/caller = "shutdown"field distinguishing the two call sites that share the samerestore_stale_breadcrumbfunction). Hook events:hook_slot_timeout,hook_slot_timeout_aborted,hook_slot_started,hook_slot_aborted(dormantd/src/hooks.rs).
- Log events: grep for
- Web security/pairing events:
web_reject_host,web_reject_origin(dormant-web/src/security.rs);pair_started,pair_succeeded,pair_failed(dormant-web/src/routes/pair.rs, never carrying the token as a field);entity_created,entity_deleted(dormant-web/src/routes/config_apply.rs::post_apply, fired only after check_patches, apply_patches, daemon-identical validate, backup, and the atomic rename have ALL succeeded — never on a rejected patch — and carryingcollection+idonly, never the entity'svalue). Thefeature_disabled/pairing_in_progress/pair_not_found/entity_existsstrings are HTTP JSONerrorbodies, nottracingevents — greperror.rsindormant-web/src, notevent = "...", to find them. - Wire events (
DaemonEventindormant-core/src/rules.rs): tag isevent—sensor_changed,zone_changed,display_phase,pause_changed,config_reloaded,wake_retry,wake_recovered,blank_failure,blank_recovered,wear_snapshot,wear_sampling_started,wear_sampling_degraded,wear_sampling_source_gate,compensation_advisory,ownership,operations_changed. Pairing status is a request/response IPC and loopback-HTTP contract, not a daemon event. ADaemonEvent::Subscribedsentinel is the first frame emitted on a fresh event-stream connection (per-connection, never broadcast). - Snapshot keys (
DisplaySnapshotindormant-core/src/rules.rs):scope,owned,observed_input_code, andpanel_stateexpose shared-display ownership and local hardware observations;wake_attemptsandlast_blank_failedexpose controller recovery state.Blank shared panel — affects all connected machinesis the tray's force-blank label for shared displays.
Config keys follow the TOML path: daemon.log_level (bare debug/trace scopes to dormant crates via dormantd/src/logging.rs with an info global floor for external dependencies; explicit EnvFilter directives pass through unchanged), sensors.<id>.type, zones.<id>.mode, displays.<id>.controllers, displays.<id>.panel_type, displays.<id>.hooks.timeout, rules.<id>.zone, coordination.<key> (poll_interval, state_poll_interval defaults to max(30s, poll_interval), loss_confirmations, flap_threshold defaults to 5, flap_window defaults to "120s", flap_settle defaults to "60s", activity_follow, arm_after, cooldown; duration floors must be >= poll_interval), wear.<key>, notifications.<key>, watchdog.<key>, publish.<key> (enabled, broker_url, base_topic, discovery_prefix, instance_id), etc. — all resolved in dormant-core/src/config/mod.rs.
dormant attributes per-display brightness-weighted on-time to a coarse
grid overlaid on each panel — the operator sees a heat map (and a
"compensation advisory" nudge) and can reason about uneven burn-in risk.
Three concerns, kept cleanly separated:
- Pure model —
crates/dormant-core/src/wear.rs(WearLedger,WearIdentity,PanelType,sanitize_identity_key,brightness_norm,WearHandle = Arc<RwLock<HashMap<String, WearLedger>>>). No I/O; the tracker indormantdowns reading/writing/scheduling.brightness_normscales per-controller readbacks to0.0..=1.0using the controller'snative_max(DDC/CI100, Samsung port-151650). - Sample + persist loop —
crates/dormantd/src/wear_tracker.rs. A puretick(snapshot, samples, config, now)advances the in-memory ledgers (attribution, dwell tracking, advisory latch, persist-due bookkeeping) and returnsTrackerActions; the async shell owns file I/O, event publication, and per-display ledger creation (load_or_create_ledger— corrupt/unreadable file recovery, future-schema read-only mode). The shell samples displays in theactiveORgracephase (spec §4.2 pins both to the same attribution row — a display in its grace period still gets a real brightness read, not the fallback) throughCommandSink::read_state_sampled(the sampler-priority variant ofread_state); every other phase attributes a fixed factor and needs no hardware read. Controllers with a single physical bus (DDC/CI) implement the variant under thecrates/dormant-displays/src/vcp_ops.rs+crates/dormant-displays/src/ddc_lock.rspanel-lock discipline —VcpPriority::Commandblocks until the panel is free;VcpPriority::Samplerdoes a double-checkedtry_lockand yields instantly to any command-path caller that announced itself. - Wire events —
DaemonEvent::WearSnapshotandDaemonEvent::CompensationAdvisory(additive variants incrates/dormant-core/src/rules.rs; the#[serde(other)] Unknowncatch-all keeps older CLIs/WebUI builds streaming past foreign tags).
Identity uses the panel-derived key from
DisplayController::panel_identity() when one is exposed (DDC/CI's
canonical panel-lock key, Samsung's "samsung:<host>") so a
[displays.*] config rename doesn't orphan or collide with an existing
ledger; controllers with no panel-derived identity (command,
kwin-dpms, ha-passthrough) fall back to the sanitized config
display key. seeded_usage_hours (from read_usage_hours, DDC/CI VCP
0xC0) seeds the ledger's prior-on-hours if the panel was not new
when tracking started.
Persistence path: dormant_core::paths::wear_state_dir() returns
$XDG_STATE_HOME/dormant/wear (or $HOME/.local/state/dormant/wear as
fallback) — one ledger file per tracked display, baselined at
advisory_baseline_epoch_s (the "assume-healthy" anchor for the
advisory formula).
Configuration: the top-level [wear] section (WearConfig in
crates/dormant-core/src/config/schema.rs, defaults in
crates/dormant-core/src/config/defaults.rs::WEAR_*) — enabled,
sample_interval, persist_interval, read_timeout, grid_rows,
grid_cols, fallback_brightness, screensaver_factor,
short_cycle_dwell, advisory_after. Panel technology
([displays.<id>].panel_type, woled/qd-oled/unknown) is
config-declared (never auto-detected — see the doctor/wear spec) and
recorded on the ledger, but v1's attribution math does not yet branch on
it — it is stored now as the bridge for a later per-channel weighting
without a schema break, not a live heuristic today.
Exposure: GET /api/wear (per-display summary; advisory is
server-derived from wear.advisory_after and
max(last_long_dwell_epoch_s, advisory_baseline_epoch_s), independent
of any WS nudge the client may have missed) and
GET /api/wear/<display> (summary + per-cell cells + min-max
normalized heat) at crates/dormant-web/src/routes/wear.rs.
dormantd/src/notifier.rs surfaces repeated wake-command failures and
one-shot blank-command failures as desktop notifications, split the same
way as wear_tracker.rs: pure decide/reconcile policy functions (no
I/O) mutate a daemon-lifetime NotifyState (one open episode per
(display, kind)) and return actions; an async shell drives the
NotifySink trait, whose production impl (ZbusSink) calls
org.freedesktop.Notifications over the session D-Bus with a 2s
per-call timeout and a 60s reconnect backoff. NotifyState and the
ZbusSink's cached connection are constructed once in App::start and
threaded unchanged through every reload generation, so open episodes (and
their D-Bus notification ids) survive a reload.
Reload can void carried-forward failure evidence: dormantd/src/reload.rs's
zero_changed_displays zeroes a display's wake_attempts/
last_blank_failed before they are seeded into the new generation if the
display's dispatch-relevant config changed (per dispatch_relevant_eq —
controllers, blank/degraded mode, ladder, output/DDC target, host/WoL MAC,
blank/wake command or service+data, modes, command timeout, or the
unreachable-as-blanked flag) or if the display was added/removed. The
notifier's post-reload reconcile then closes any now-stale open
notification for that display without a recovery notice — reconcile
never emits one, unlike a genuine WakeRecovered/BlankRecovered event.
Both wake_attempts and last_blank_failed are plain in-memory
DisplaySnapshot fields with no on-disk persistence (unlike the wear
ledger above) — a full daemon restart, as opposed to a config reload,
loses all open episodes and failure counters. See
docs/src/failure-notifications.md
for the full trigger/threshold/cooldown semantics and the config keys.
Three composed mechanisms, kept separate the same way wear-tracking and failure-notifications are — pure decision logic with no I/O, driven by a thin async/sync shell:
- Pure verdict logic —
crates/dormantd/src/boot_guard.rs: [decide] (the §5.2 crash-loop verdict matrix:Proceed/RollBack/ContinueRollback, zero I/O) and [should_promote] (the §4 LKG-promotion gate:Wait/Promote/SkipDirty/DeferHealth/PromoteDespiteHealth). The documented constsCRASH_LOOP_THRESHOLD(3),CRASH_LOOP_WINDOW(6m), andLKG_HEALTH_DEFER_CAP(3) live here. - Sync I/O shell —
boot_guard::preparerecords this boot's start entry, buildsLkgInfo, callsdecide, and performs every verdict-drivencrash-loop.jsonwrite itself, all before logging is initialised (events are deferred into the returnedBootPlanand emitted bydormantd/src/main.rs::emit_deferred_eventsonce it is). - Structural ordering shell —
crates/dormantd/src/main_sequence.rsenforces startup's non-negotiable order throughrun_startup_sequence(the closure chainrestore_stale_gamma → peek_boot_options → boot_guard::prepare → boot) so no early-return insideboot::bootcan bypass the stale-gamma restore, the config peek, orprepare's verdict-driven writes; its inverserun_boot_with_shutdown_restorewraps the boot future so every verdict path — lock-failed, build-failed, started, run-loop-failed, clean shutdown — converges on the same shutdown gamma-restore.main.rsowns the I/O closures (real gamma calls, real config load, real boot); the shell owns the order, kept testable on every platform. - The boot sequence —
crates/dormantd/src/boot.rs::boot(plan, inputs)owns the single flock acquire, the oneApp::build/App::startcall site, the one boot()-owned crash-loop write (the immediate-rollback path, whichdecidecan't predict since it never validates the chosen config), and parking the pending-reload banner throughAppHandle::control_sender(). - The watchdog probe arm —
crates/dormantd/src/app.rs'sRunner::watchdog_tick/lkg_tick: a periodicrun_looparm that pingsWATCHDOG=1(viasd_notify.rs'sSdNotify) only after the engine answers a snapshot round-trip on the same control channelreload()uses, and runs the LKG-promotion check on every healthy tick.reload()itself pings at internal step boundaries (post-validate, post-quiesce, post-teardown, per removed-display wake, pre-rebuild_old) so a slow but healthy reload is never mistaken for a wedge.
State files live at paths::state_dir() root (sibling of, not nested
inside, wear_state_dir()): last-known-good.toml,
last-known-good.meta.json, crash-loop.json, discount-<nonce>.
[watchdog] config (WatchdogConfig in dormant-core/src/config/schema.rs,
defaults in defaults.rs::LKG_*, the stability_window >= 30s floor in
validate.rs::validate_watchdog) covers lkg_enabled,
lkg_rollback_enabled, stability_window — WatchdogSec and ping cadence
are deliberately not config; they come from systemd's own
NOTIFY_SOCKET/WATCHDOG_USEC environment. See
docs/src/watchdog-rollback.md for the
full mechanism writeup, the WatchdogSec=150 derivation, and the
binary-before-unit upgrade order warning.
No macOS watchdog parity. sd_notify.rs and the engine-liveness
WATCHDOG=1 ping are systemd-only — launchd has no equivalent mechanism
to detect a running but wedged (hung, not exited) dormantd. The
crash-loop / LKG-rollback logic above is otherwise fully
supervisor-agnostic (it counts process starts from its own state files,
not from systemd or launchd), so launchd's ThrottleInterval=10 paces
restarts the same way systemd's RestartSec=2 does, and a
config-caused crash loop still rolls back to last-known-good on macOS —
only the wedged-but-alive detection is Linux/systemd-only. See
Installation: LaunchAgent (macOS).
DPMS-based blanking (including kwin-dpms) disables the DRM/KMS output,
which tears down the associated ALSA audio sink — audio dies along with the
picture. This is architectural, not a config setting.
Three display-controller modes blank without tearing down the output, preserving audio:
ddcci— VCP0xD6sends a "display power off" command over I2C. The monitor blanks its panel internally; the OS output and ALSA device remain active. Only works on DDC/CI-capable monitors that support D6. Cross-platform: same VCP semantics on Linux and macOS.samsung-tizen—KEY_PICTURE_OFFblanks the TV panel over WebSocket. The TV continues rendering audio; the HDMI output remains active.samsung-tizen(brightness_zero) — Samsung IP Control G2 JSON-RPC on port 1516 callsbacklightControlto dim the panel to 0 viasamsung_ip. Source and audio keep running; the HDMI output stays active. Used whenKEY_PICTURE_OFFwould cut the source or pause media.macos-gamma-black(macOS only) — writes an all-zero Quartz gamma table via CoreGraphics. A color-LUT black, not a power-state or output change, so it never touches audio. Trades that safety for a restart caveat: no periodic reassertion, restore depends on an in-process captured table or the on-disk breadcrumb — see Displays: macOS gamma black.
macos-display-sleep (macOS's last-resort fallback, pmset displaysleepnow) is deliberately not in the audio-safe list above —
unlike the LUT-level and DDC/CI-internal modes, it is a real, whole-machine
hardware/firmware display sleep with no audio-safety guarantee.
Per-display strategy:
- DDC/CI monitor →
ddccipower_off (audio-safe, verified on AOC AG326UZD; same controller on Linux and macOS) - Samsung Tizen TV →
samsung-tizenpicture-off (audio-safe, verified on S90D) - Samsung Tizen TV where the source must keep running →
samsung-tizenbrightness_zerovia port 1516 IP Control G2 (audio-safe, softer panel change — does not pause media) - Outputs with no DDC/CI and no audio →
kwin-dpmsis acceptable (no audio to kill) - Outputs with audio but no DDC/CI → Tizen passthrough or
commandwith an audio-safe external command; otherwise live with the audio loss - macOS display with no DDC/CI support →
macos-gamma-black(audio-safe), falling back tomacos-display-sleep(not audio-safe, last resort) — the plan-recommended chain is["ddcci", "macos-gamma-black", "macos-display-sleep"], see Displays: recommended macOS chain
See docs/research/2026-07-05-kwin-dpms-verification.md and
docs/research/2026-07-05-s90d-verification.md for the hardware spike data.