Skip to content

Latest commit

 

History

History
382 lines (337 loc) · 41.4 KB

File metadata and controls

382 lines (337 loc) · 41.4 KB

Architecture

Crate map, data flow, and where-to-find-it guide for the dormant codebase.

Crate map

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.

Data flow

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)
  1. Sensors produce PresenceEvent values (occupied / vacant) and push them to the zone engine.
  2. 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).
  3. 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 via pw-dump polling; a rule reacts only to the kinds it declares in inhibitors). Kinds OR together per rule (any declared kind active holds the blank). Manual pause (Overlays.paused, driven by dormantctl pause/resume or the web UI) is a separate mechanism, not one of these inhibitor kinds.
  4. 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.
  5. For scope = "shared", the coordination poller reads local VCP 0x60 on every coordination.poll_interval tick before the executor acts; it refreshes brightness/power cosmetics only at the slower effective coordination.state_poll_interval cadence (default max(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.

Shared-panel direct switch flow

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.

Filtered-activity fan-out

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.

Where do I look for X?

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)

Event and error-code grep anchors

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 in dormant-core/src/error.rs.
    • Log events: grep for event = "..." in the source. Key events include display_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 in dormantd/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 in dormantd/src/gamma_recovery.rs, with the caller = "startup" / caller = "shutdown" field distinguishing the two call sites that share the same restore_stale_breadcrumb function). Hook events: hook_slot_timeout, hook_slot_timeout_aborted, hook_slot_started, hook_slot_aborted (dormantd/src/hooks.rs).
  • 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 carrying collection+id only, never the entity's value). The feature_disabled/pairing_in_progress/pair_not_found/entity_exists strings are HTTP JSON error bodies, not tracing events — grep error.rs in dormant-web/src, not event = "...", to find them.
  • Wire events (DaemonEvent in dormant-core/src/rules.rs): tag is event — 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. A DaemonEvent::Subscribed sentinel is the first frame emitted on a fresh event-stream connection (per-connection, never broadcast).
  • Snapshot keys (DisplaySnapshot in dormant-core/src/rules.rs): scope, owned, observed_input_code, and panel_state expose shared-display ownership and local hardware observations; wake_attempts and last_blank_failed expose controller recovery state. Blank shared panel — affects all connected machines is 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.

Panel-wear tracking

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 in dormantd owns reading/writing/scheduling. brightness_norm scales per-controller readbacks to 0.0..=1.0 using the controller's native_max (DDC/CI 100, Samsung port-1516 50).
  • Sample + persist loop — crates/dormantd/src/wear_tracker.rs. A pure tick(snapshot, samples, config, now) advances the in-memory ledgers (attribution, dwell tracking, advisory latch, persist-due bookkeeping) and returns TrackerActions; 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 the active OR grace phase (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) through CommandSink::read_state_sampled (the sampler-priority variant of read_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 the crates/dormant-displays/src/vcp_ops.rs + crates/dormant-displays/src/ddc_lock.rs panel-lock discipline — VcpPriority::Command blocks until the panel is free; VcpPriority::Sampler does a double-checked try_lock and yields instantly to any command-path caller that announced itself.
  • Wire events — DaemonEvent::WearSnapshot and DaemonEvent::CompensationAdvisory (additive variants in crates/dormant-core/src/rules.rs; the #[serde(other)] Unknown catch-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.

Failure notifications

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.

Watchdog and rollback

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 consts CRASH_LOOP_THRESHOLD (3), CRASH_LOOP_WINDOW (6m), and LKG_HEALTH_DEFER_CAP (3) live here.
  • Sync I/O shell — boot_guard::prepare records this boot's start entry, builds LkgInfo, calls decide, and performs every verdict-driven crash-loop.json write itself, all before logging is initialised (events are deferred into the returned BootPlan and emitted by dormantd/src/main.rs::emit_deferred_events once it is).
  • Structural ordering shell — crates/dormantd/src/main_sequence.rs enforces startup's non-negotiable order through run_startup_sequence (the closure chain restore_stale_gamma → peek_boot_options → boot_guard::prepare → boot) so no early-return inside boot::boot can bypass the stale-gamma restore, the config peek, or prepare's verdict-driven writes; its inverse run_boot_with_shutdown_restore wraps 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.rs owns 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 one App::build/App::start call site, the one boot()-owned crash-loop write (the immediate-rollback path, which decide can't predict since it never validates the chosen config), and parking the pending-reload banner through AppHandle::control_sender().
  • The watchdog probe arm — crates/dormantd/src/app.rs's Runner::watchdog_tick/lkg_tick: a periodic run_loop arm that pings WATCHDOG=1 (via sd_notify.rs's SdNotify) only after the engine answers a snapshot round-trip on the same control channel reload() 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).

Audio-safe blanking

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 — VCP 0xD6 sends 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_OFF blanks 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 calls backlightControl to dim the panel to 0 via samsung_ip. Source and audio keep running; the HDMI output stays active. Used when KEY_PICTURE_OFF would 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 → ddcci power_off (audio-safe, verified on AOC AG326UZD; same controller on Linux and macOS)
  • Samsung Tizen TV → samsung-tizen picture-off (audio-safe, verified on S90D)
  • Samsung Tizen TV where the source must keep running → samsung-tizen brightness_zero via port 1516 IP Control G2 (audio-safe, softer panel change — does not pause media)
  • Outputs with no DDC/CI and no audio → kwin-dpms is acceptable (no audio to kill)
  • Outputs with audio but no DDC/CI → Tizen passthrough or command with 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 to macos-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.