Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / ddcd / src / lib.rs

Back to remindwall — Current RemindWall

Download this file

//! ddcd - localhost DDC brightness control for the RemindWall kiosk.
//!
//! The sandboxed Mac Catalyst app (TestFlight requires App Sandbox, which kills
//! both posix_spawn of Homebrew binaries AND IOKit DDC access) talks to this
//! daemon over 127.0.0.1 instead. The daemon shells out to m1ddc with a hard
//! per-call timeout and a mutex so DDC/I2C transactions never interleave -
//! the two failure modes a bare NSUserUnixTask path couldn't handle.
//!
//! Security model: this box also serves public traffic (hotchkiss.io), so
//! localhost is NOT a trust boundary. Every request must carry the `x-ddcd`
//! header - browsers can't attach custom headers cross-origin without a CORS
//! preflight, and we reject preflights (OPTIONS) outright, so browser-based
//! CSRF is structurally impossible. Requests bearing an Origin header are
//! refused for the same reason.

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use axum::body::Body;
use axum::extract::State;
use axum::http::{HeaderMap, Method, Request, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tokio::sync::Mutex;

/// Header every caller must set. The value is irrelevant; presence is the gate.
pub const GUARD_HEADER: &str = "x-ddcd";

/// Sanity bounds for DDC max luminance. Real monitors report ~100; a value
/// like 25600 means a byte-offset bug in the m1ddc build (shipped in HEAD
/// builds Apr 2025 - Jun 2026) and every computed brightness would be garbage.
const MAX_LUMINANCE_RANGE: std::ops::RangeInclusive<i64> = 1..=1000;

#[derive(Clone)]
pub struct Config {
    pub m1ddc_path: PathBuf,
    /// Display power: probe-verified mechanism on the kiosk (2026-07-03) —
    /// `pmset displaysleepnow` for off, `caffeinate -u` for wake. Wake never
    /// depends on a DDC command reaching a sleeping panel.
    pub pmset_path: PathBuf,
    pub caffeinate_path: PathBuf,
    /// CONFIGURED, never read from the panel. The kiosk's LG corrupts every
    /// DDC read — and corrupted max values can pass any plausibility check
    /// (a garbage "62" reads as sane) and then mis-scale every write. The
    /// DDC standard value is 100; override with DDCD_MAX_LUMINANCE for the
    /// rare monitor that differs.
    pub max_luminance: i64,
    pub timeout: Duration,
    pub retry_delays: Vec<Duration>,
}

impl Config {
    /// Locates m1ddc in the standard Homebrew locations.
    pub fn from_env() -> Self {
        let m1ddc_path = std::env::var("DDCD_M1DDC")
            .map(PathBuf::from)
            .unwrap_or_else(|_| {
                ["/opt/homebrew/bin/m1ddc", "/usr/local/bin/m1ddc"]
                    .iter()
                    .map(PathBuf::from)
                    .find(|p| p.exists())
                    .unwrap_or_else(|| PathBuf::from("/opt/homebrew/bin/m1ddc"))
            });
        let timeout_ms = std::env::var("DDCD_TIMEOUT_MS")
            .ok()
            .and_then(|v| v.parse().ok())
            .unwrap_or(5_000u64);
        let max_luminance = std::env::var("DDCD_MAX_LUMINANCE")
            .ok()
            .and_then(|v| v.parse().ok())
            .filter(|v| MAX_LUMINANCE_RANGE.contains(v))
            .unwrap_or(100);
        Self {
            m1ddc_path,
            pmset_path: std::env::var("DDCD_PMSET")
                .map(PathBuf::from)
                .unwrap_or_else(|_| PathBuf::from("/usr/bin/pmset")),
            caffeinate_path: std::env::var("DDCD_CAFFEINATE")
                .map(PathBuf::from)
                .unwrap_or_else(|_| PathBuf::from("/usr/bin/caffeinate")),
            max_luminance,
            timeout: Duration::from_millis(timeout_ms),
            retry_delays: Self::default_retry_delays(),
        }
    }

    pub fn default_retry_delays() -> Vec<Duration> {
        vec![Duration::from_millis(300), Duration::from_millis(900)]
    }
}

#[derive(Debug)]
pub enum DdcError {
    /// m1ddc didn't answer within the timeout - a wedged I2C transaction.
    /// The child is killed (kill_on_drop), so it can't accumulate.
    Timeout,
    /// The binary is missing/not executable.
    Unavailable(String),
    /// m1ddc ran and failed. Note m1ddc prints errors to STDOUT, not stderr.
    Failed(String),
    /// Output that doesn't parse, or a max luminance outside sane bounds.
    BadOutput(String),
}

impl IntoResponse for DdcError {
    fn into_response(self) -> Response {
        let (status, msg) = match self {
            DdcError::Timeout => (StatusCode::GATEWAY_TIMEOUT, "m1ddc timed out (wedged DDC transaction?)".to_string()),
            DdcError::Unavailable(m) => (StatusCode::SERVICE_UNAVAILABLE, m),
            DdcError::Failed(m) => (StatusCode::BAD_GATEWAY, m),
            DdcError::BadOutput(m) => (StatusCode::BAD_GATEWAY, m),
        };
        tracing::warn!(%msg, "ddc operation failed");
        (status, Json(serde_json::json!({ "error": msg }))).into_response()
    }
}

/// Serialized, timeout-guarded m1ddc invocations. DDC/I2C is a single-master
/// bus; concurrent transactions corrupt each other.
pub struct Ddc {
    path: PathBuf,
    pmset_path: PathBuf,
    caffeinate_path: PathBuf,
    /// Configured, never read - see Config::max_luminance.
    max_luminance: i64,
    timeout: Duration,
    lock: Mutex<()>,
    /// Backoff schedule for transient failures - the DCP AV service takes a
    /// few seconds to return after display wake, and a restore issued right
    /// after wake shouldn't fail spuriously. Timeouts are NOT retried (a
    /// wedged transaction already cost its full timeout).
    retry_delays: Vec<Duration>,
}

impl Ddc {
    pub fn new(config: &Config) -> Self {
        Self {
            path: config.m1ddc_path.clone(),
            pmset_path: config.pmset_path.clone(),
            caffeinate_path: config.caffeinate_path.clone(),
            max_luminance: config.max_luminance,
            timeout: config.timeout,
            lock: Mutex::new(()),
            retry_delays: config.retry_delays.clone(),
        }
    }

    pub fn binary_present(&self) -> bool {
        self.path.exists()
    }

    /// Spawns a tool with the hard timeout; kill_on_drop reaps a wedged child.
    /// Callers hold the hardware lock — one physical display, one op at a time.
    async fn run_tool(&self, path: &PathBuf, args: &[&str]) -> Result<String, DdcError> {
        let child = Command::new(path)
            .args(args)
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped())
            .kill_on_drop(true)
            .spawn()
            .map_err(|e| DdcError::Unavailable(format!("cannot spawn {}: {e}", path.display())))?;

        let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
            // Dropping the future kills the child via kill_on_drop.
            Err(_) => return Err(DdcError::Timeout),
            Ok(Err(e)) => return Err(DdcError::Failed(format!("wait failed: {e}"))),
            Ok(Ok(output)) => output,
        };

        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
            return Err(DdcError::Failed(format!(
                "{} {} exited {}: {stdout} {stderr}",
                path.file_name().map_or("tool".into(), |n| n.to_string_lossy()),
                args.join(" "),
                output.status.code().map_or("signal".to_string(), |c| c.to_string()),
            )));
        }
        Ok(stdout)
    }

    async fn run(&self, args: &[&str]) -> Result<String, DdcError> {
        let _guard = self.lock.lock().await;
        self.run_tool(&self.path.clone(), args).await
    }

    /// True display power. Off = OS display sleep (panel enters real standby
    /// on signal loss); on = user-activity assertion (panel wakes on signal
    /// return). Probe-verified on the kiosk's LG: wake works even from SSH,
    /// and DDC transactions survive the whole transition.
    pub async fn set_display_power(&self, on: bool) -> Result<(), DdcError> {
        let _guard = self.lock.lock().await;
        if on {
            // -t 2 keeps the assertion long enough for the panel to latch on;
            // the call blocks those ~2s, bounded by the hard timeout.
            self.run_tool(&self.caffeinate_path.clone(), &["-u", "-t", "2"]).await?;
        } else {
            self.run_tool(&self.pmset_path.clone(), &["displaysleepnow"]).await?;
        }
        Ok(())
    }

    /// Bounded retry for transient failures (panel wake, marginal DDC read).
    /// Retries only `Failed`/`BadOutput` — a `Timeout` already burned its full
    /// budget on a wedged transaction, and `Unavailable` won't heal by waiting.
    async fn run_with_retry(&self, args: &[&str]) -> Result<String, DdcError> {
        let mut last_err = None;
        for (attempt, delay) in std::iter::once(None)
            .chain(self.retry_delays.iter().map(Some))
            .enumerate()
        {
            if let Some(delay) = delay {
                tokio::time::sleep(*delay).await;
            }
            match self.run(args).await {
                Ok(out) => return Ok(out),
                Err(e @ (DdcError::Timeout | DdcError::Unavailable(_))) => return Err(e),
                Err(e) => {
                    tracing::warn!(args = args.join(" "), attempt, error = ?e, "ddc attempt failed");
                    last_err = Some(e);
                }
            }
        }
        Err(last_err.expect("at least one attempt ran"))
    }

    /// Reads a numeric value, retrying attempts whose output fails the
    /// plausibility check. DDC reads on flaky panels (LG especially) return
    /// corrupted values without any checksum protection — the kiosk's monitor
    /// has produced `luminance -51 / max 62` in the field. A corrupted read
    /// is a transient failure, not an answer.
    async fn read_validated(
        &self,
        args: &[&str],
        valid: impl Fn(i64) -> bool,
    ) -> Result<i64, DdcError> {
        let mut last_err = None;
        for (attempt, delay) in std::iter::once(None)
            .chain(self.retry_delays.iter().map(Some))
            .enumerate()
        {
            if let Some(delay) = delay {
                tokio::time::sleep(*delay).await;
            }
            match self.run(args).await {
                Ok(out) => match out.parse::<i64>() {
                    Ok(value) if valid(value) => return Ok(value),
                    Ok(value) => {
                        tracing::warn!(args = args.join(" "), attempt, value, "implausible DDC read");
                        last_err = Some(DdcError::BadOutput(format!(
                            "m1ddc {} returned implausible value {value} (corrupted DDC read?)",
                            args.join(" ")
                        )));
                    }
                    Err(_) => {
                        last_err = Some(DdcError::BadOutput(format!(
                            "m1ddc {} returned non-numeric: {out:?}",
                            args.join(" ")
                        )));
                    }
                },
                Err(e @ (DdcError::Timeout | DdcError::Unavailable(_))) => return Err(e),
                Err(e) => {
                    tracing::warn!(args = args.join(" "), attempt, error = ?e, "ddc attempt failed");
                    last_err = Some(e);
                }
            }
        }
        Err(last_err.expect("at least one attempt ran"))
    }

    pub fn max_luminance(&self) -> i64 {
        self.max_luminance
    }

    /// Diagnostic only — the plausibility check bounds readings to the
    /// configured max, and the write path never depends on this.
    pub async fn get_luminance(&self) -> Result<i64, DdcError> {
        let max = self.max_luminance;
        self.read_validated(&["get", "luminance"], |v| (0..=max).contains(&v))
            .await
    }

    pub async fn set_luminance(&self, value: i64) -> Result<(), DdcError> {
        self.run_with_retry(&["set", "luminance", &value.to_string()])
            .await
            .map(|_| ())
    }
}

pub struct AppState {
    pub ddc: Ddc,
}

pub fn app(config: Config) -> Router {
    let state = Arc::new(AppState {
        ddc: Ddc::new(&config),
    });

    Router::new()
        .route("/health", get(health))
        .route("/brightness", get(get_brightness).put(put_brightness))
        .route("/display", axum::routing::put(put_display))
        .layer(middleware::from_fn(guard))
        .with_state(state)
}

/// The CSRF/SSRF gate - see module docs.
async fn guard(request: Request<Body>, next: Next) -> Response {
    if request.method() == Method::OPTIONS {
        return StatusCode::METHOD_NOT_ALLOWED.into_response();
    }
    if has_origin(request.headers()) {
        return (StatusCode::FORBIDDEN, "cross-origin requests are refused").into_response();
    }
    if request.headers().get(GUARD_HEADER).is_none() {
        return (StatusCode::FORBIDDEN, "missing x-ddcd header").into_response();
    }
    next.run(request).await
}

fn has_origin(headers: &HeaderMap) -> bool {
    headers.get(axum::http::header::ORIGIN).is_some()
}

#[derive(Serialize)]
struct Health {
    status: &'static str,
    m1ddc_present: bool,
}

async fn health(State(state): State<Arc<AppState>>) -> Json<Health> {
    Json(Health {
        status: "ok",
        m1ddc_present: state.ddc.binary_present(),
    })
}

#[derive(Serialize)]
struct Brightness {
    brightness: f64,
    raw: i64,
    max: i64,
}

async fn get_brightness(State(state): State<Arc<AppState>>) -> Result<Json<Brightness>, DdcError> {
    let max = state.ddc.max_luminance();
    let raw = state.ddc.get_luminance().await?;
    Ok(Json(Brightness {
        brightness: (raw as f64 / max as f64).clamp(0.0, 1.0),
        raw,
        max,
    }))
}

#[derive(Deserialize)]
struct SetBrightness {
    brightness: f64,
}

#[derive(Deserialize)]
struct SetDisplayPower {
    on: bool,
}

async fn put_display(
    State(state): State<Arc<AppState>>,
    Json(body): Json<SetDisplayPower>,
) -> Result<StatusCode, DdcError> {
    state.ddc.set_display_power(body.on).await?;
    tracing::info!(on = body.on, "display power");
    Ok(StatusCode::NO_CONTENT)
}

async fn put_brightness(
    State(state): State<Arc<AppState>>,
    Json(body): Json<SetBrightness>,
) -> Result<StatusCode, Response> {
    if !(0.0..=1.0).contains(&body.brightness) || !body.brightness.is_finite() {
        return Err((
            StatusCode::UNPROCESSABLE_ENTITY,
            Json(serde_json::json!({ "error": "brightness must be within 0.0...1.0" })),
        )
            .into_response());
    }

    // Pure write: max is configuration, the panel is never asked. On the
    // kiosk's LG every read is corrupted, and a write path that reads first
    // fails every write (found live, 2026-07-03).
    let max = state.ddc.max_luminance();
    let target = (body.brightness * max as f64).round() as i64;
    state
        .ddc
        .set_luminance(target)
        .await
        .map_err(IntoResponse::into_response)?;

    tracing::info!(brightness = body.brightness, target, max, "set luminance");
    Ok(StatusCode::NO_CONTENT)
}