Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/bin/souveraine-sensord.rs
Fimeg de3a332513 sensord: report edges, not every sample
Light drifts continuously; monitor-sensor emitted ~8 lines/sec and each was
a socket round trip re-asserting the same Changing(false). The keepalive is
what keeps silence meaningful, so only changes need reporting.
2026-07-26 20:03:21 -04:00

317 lines
13 KiB
Rust

//! souveraine-sensord — one reporter for every iio-sensor-proxy source.
//!
//! REPORTER, NOT AN AUTHORITY. It reads sensors and tells sessiond. It decides
//! nothing, actuates nothing, and reads no lock state. Every decision belongs
//! to the device state machine (`DEVICE-STATE-MACHINE.md` §1) — the whole point
//! of that document is that we had seven actors each seeing one facet and
//! acting on it blindly.
//!
//! ## Why this replaces the scripts
//!
//! `blueline-proximity-lock` is a 113-line shell script implementing a contract
//! that is genuinely subtle: heartbeat inside `SOURCE_DOWN_AFTER`, seed the
//! last value from the startup probe banner, report both edges, pass the last
//! reading between a subshell and a background loop through a file in
//! `$XDG_RUNTIME_DIR`. All of that is correct and all of it would have to be
//! copied, verbatim and by hand, into a second script for light and a third for
//! accel — §12 says decide this before writing the second one, not after the
//! third.
//!
//! Copies drift. The `blueline-*` units are exactly the "script-based random
//! stuff" that has to become one orchestrated thing.
//!
//! ## The contract it implements (§10)
//!
//! `monitor-sensor` emits only on CHANGE, so a phone on a table is silent for
//! hours and is byte-for-byte indistinguishable from a dead SLPI. Silence must
//! therefore be made meaningful: every source re-sends its last known reading
//! every `KEEPALIVE`, comfortably inside the machine's 90 s `SOURCE_DOWN_AFTER`.
//! As long as this process and the sensor behind it live, sessiond hears from
//! each source on a schedule. If it stops hearing, something is actually wrong.
//!
//! Repeats are idempotent: the machine re-derives identical evidence and skips
//! the trail write, so a keepalive can never move the state.
//!
//! Health is deliberately NOT read from `net.hadess.SensorProxy`'s
//! `HasProximity`/`HasAccelerometer`. §10 records it answering wrongly in both
//! directions — `true` for hours after the stack died, `false` after remoteproc
//! had already recovered. What can be trusted is our own experience of whether
//! readings arrive.
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Re-send interval for each source's last known reading. Must stay well under
/// the machine's `SOURCE_DOWN_AFTER` (90 s) so a healthy source gets several
/// chances to be heard before it is called down.
const KEEPALIVE: Duration = Duration::from_secs(30);
/// How long to wait for sessiond to accept and answer. Short: it answers in
/// microseconds when healthy, and a reporter must never block on the authority.
const SESSIOND_TIMEOUT: Duration = Duration::from_secs(5);
/// Backoff when `monitor-sensor` exits or the proxy has no sensors yet.
const RESPAWN_DELAY: Duration = Duration::from_secs(5);
/// The last reading we sent per source, in the exact JSON shape sessiond's
/// `SensorInput` expects. Shared with the keepalive thread.
type LastSeen = Arc<Mutex<HashMap<&'static str, serde_json::Value>>>;
fn socket_path() -> PathBuf {
let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| {
format!("/run/user/{}", unsafe { libc_getuid() })
});
PathBuf::from(runtime).join("souveraine/sessiond.sock")
}
/// Avoid a libc dependency for one call.
unsafe fn libc_getuid() -> u32 {
std::fs::read_to_string("/proc/self/loginuid")
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(1000)
}
/// Send one reading. Returns false when sessiond could not be reached — which
/// is information, not a fatal error: the daemon restarts independently of us.
fn report(sock: &PathBuf, source: &str, value: &serde_json::Value) -> bool {
let req = serde_json::json!({
"op": "sensor_input",
"source": source,
"value": value,
});
let attempt = || -> std::io::Result<()> {
let stream = UnixStream::connect(sock)?;
stream.set_read_timeout(Some(SESSIOND_TIMEOUT))?;
stream.set_write_timeout(Some(SESSIOND_TIMEOUT))?;
let mut w = stream.try_clone()?;
w.write_all(format!("{req}\n").as_bytes())?;
w.flush()?;
// Read the reply so the daemon is not left writing into a closed pipe.
let mut buf = [0u8; 4096];
let mut r = stream;
let _ = r.read(&mut buf)?;
Ok(())
};
match attempt() {
Ok(()) => true,
Err(e) => {
eprintln!("sessiond unreachable; {source} reading dropped: {e}");
false
}
}
}
/// One source's parse rules: the monitor-sensor flag, the change line it emits,
/// and how to turn that line into the value sessiond wants.
struct Source {
name: &'static str,
flag: &'static str,
/// Substring identifying a change line for this source.
change_marker: &'static str,
/// Substring identifying this source's startup banner, used to seed the
/// keepalive before the first change arrives. Without a seed, a phone that
/// booted onto a table stays silent and a stack that was dead from boot is
/// invisible.
banner_marker: &'static str,
parse: fn(&str, &mut ParserState) -> Option<serde_json::Value>,
}
/// `Proximity value changed: 0` / banner `=== Has proximity sensor (near: 0)`
fn parse_proximity(line: &str, _state: &mut ParserState) -> Option<serde_json::Value> {
let near = !(line.trim_end().ends_with(": 0") || line.contains("near: 0"));
Some(serde_json::json!({ "near": near }))
}
/// `Light changed: 240.000000 lux`
///
/// The wire carries `SensorValue::Changing(bool)`, not lux. That is what the
/// deployed sessiond accepts, and sending a shape it does not know earns an
/// `invalid_argument` refusal rather than a reading.
///
/// §12 argues lux itself should reach the machine, because auto-brightness
/// needs a level and not an edge. That is a protocol change with a delivery-
/// order problem attached (TASK-28: a new reporter against an old daemon), so
/// it is deliberately not smuggled in here. Until then "changing" means the
/// level moved by more than `LIGHT_CHANGE_LUX` since the last reading we sent.
fn parse_light(line: &str, state: &mut ParserState) -> Option<serde_json::Value> {
let lux: f64 = line
.rsplit(':')
.next()?
.split_whitespace()
.next()?
.parse()
.ok()?;
let changing = match state.last_lux {
Some(prev) => (lux - prev).abs() > LIGHT_CHANGE_LUX,
// The startup banner is a level, not a change. Reporting "changing" on
// it would hand the confidence table a presence signal produced by
// nothing more than the reporter starting up.
None => false,
};
state.last_lux = Some(lux);
Some(serde_json::json!({ "changing": changing }))
}
/// `Accelerometer orientation changed: normal`
///
/// The wire carries `Moving(bool)`. iio-sensor-proxy reports orientation, not
/// motion, so movement is inferred from the orientation changing at all — which
/// is a weak signal and is exactly why §4 gives accel 0.3 and not more. A phone
/// being carried face-up in a steady hand reports nothing.
fn parse_accel(line: &str, state: &mut ParserState) -> Option<serde_json::Value> {
let orientation = line.rsplit(':').next()?.trim().to_string();
let moving = match &state.last_orientation {
Some(prev) => *prev != orientation,
None => false,
};
state.last_orientation = Some(orientation);
Some(serde_json::json!({ "moving": moving }))
}
/// Per-source memory the parsers need to turn levels into edges.
#[derive(Default)]
struct ParserState {
last_lux: Option<f64>,
last_orientation: Option<String>,
}
/// How much lux must move before it counts as "changing". A hand passing over
/// the sensor is a large, brief swing; room lighting drifts slowly. This is a
/// placeholder in the same sense §9.5's debounce was before the trail corrected
/// it — take the real number off the trail, not out of a comment.
const LIGHT_CHANGE_LUX: f64 = 15.0;
const SOURCES: &[Source] = &[
Source {
name: "proximity",
flag: "--proximity",
change_marker: "Proximity value changed: ",
banner_marker: "Has proximity",
parse: parse_proximity,
},
Source {
name: "light",
flag: "--light",
change_marker: "Light changed: ",
banner_marker: "Has ambient light sensor",
parse: parse_light,
},
Source {
// "accelerometer" on the wire, not "accel". SensorSource derives its
// JSON from snake_case variant names; `as_str()`'s shorter "accel" is
// the trail/health key only, and sending it here earns a refusal.
name: "accelerometer",
flag: "--accel",
change_marker: "Accelerometer orientation changed: ",
banner_marker: "Has accelerometer",
parse: parse_accel,
},
];
/// Re-send every source's last reading forever. This is the half that makes
/// silence meaningful; see the module docs.
fn spawn_keepalive(sock: PathBuf, last: LastSeen) {
std::thread::spawn(move || loop {
std::thread::sleep(KEEPALIVE);
let snapshot: Vec<(&'static str, serde_json::Value)> = {
let guard = match last.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.iter().map(|(k, v)| (*k, v.clone())).collect()
};
for (source, value) in snapshot {
report(&sock, source, &value);
}
});
}
fn spawn_monitor() -> std::io::Result<Child> {
let mut cmd = Command::new("monitor-sensor");
for s in SOURCES {
cmd.arg(s.flag);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn()
}
fn main() {
let sock = socket_path();
let last: LastSeen = Arc::new(Mutex::new(HashMap::new()));
spawn_keepalive(sock.clone(), Arc::clone(&last));
// One monitor-sensor for every source, respawned if it dies. Restarting the
// process is the recovery path for a sensor stack that came back after an
// SLPI reset; the keepalive keeps reporting the stale-but-last value in the
// meantime, and the machine's own freshness rules decide what that is worth.
loop {
let child = match spawn_monitor() {
Ok(c) => c,
Err(e) => {
eprintln!("could not start monitor-sensor: {e}");
std::thread::sleep(RESPAWN_DELAY);
continue;
}
};
let stdout = match child.stdout {
Some(s) => s,
None => {
std::thread::sleep(RESPAWN_DELAY);
continue;
}
};
let mut state = ParserState::default();
for line in BufReader::new(stdout).lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
for s in SOURCES {
// Seed from the banner so the keepalive has something to send
// before the first change ever arrives.
let is_banner = line.contains(s.banner_marker);
if !is_banner && !line.contains(s.change_marker) {
continue;
}
let Some(value) = (s.parse)(&line, &mut state) else { continue };
// Only report when the value the machine sees actually
// changes. monitor-sensor emits a line per lux sample and
// ambient light drifts continuously — measured on device at
// ~8 lines/second, every one of them a socket round trip
// carrying `Changing(false)` again. The keepalive already
// guarantees sessiond hears from every source inside
// SOURCE_DOWN_AFTER, so silence between real changes is safe
// and is exactly what §10 arranged for.
let unchanged = {
let mut guard = match last.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
let same = guard.get(s.name) == Some(&value);
guard.insert(s.name, value.clone());
same
};
if unchanged {
break;
}
// Report both edges. "Far" matters as much as "near": it is
// what re-enables a wake, and it is what proves the sensor is
// still alive.
report(&sock, s.name, &value);
break;
}
}
eprintln!("monitor-sensor exited; respawning");
std::thread::sleep(RESPAWN_DELAY);
}
}