remindwall / ddcd / src / lib.rs
Back to remindwall — Current RemindWall
//! 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)
}