Watch
1
0
Fork
You've already forked souveraine
0

sensord: one reporter for every sensor, replacing the per-sensor scripts

blueline-proximity-lock is 113 lines of shell implementing a subtle contract
(heartbeat inside SOURCE_DOWN_AFTER, seed from the probe banner, report both
edges) that would have been hand-copied for light and again for accel. One
binary, one contract, shipped in the package instead of the rootfs overlay.
This commit is contained in:
Fimeg 2026-07-26 18:46:27 -04:00
commit 83fab1a1b6
5 changed files with 356 additions and 3 deletions

View file

@ -119,6 +119,13 @@ jobs:
file "$SBIN" | grep -q aarch64 || { echo "sessiond is not aarch64" >&2; exit 1; }
cp "$SBIN" souveraine-sessiond-aarch64
# The sensor reporter. Same story as sessiond: it replaces shell
# scripts that shipped in rootfs-overlay/ and drifted per-device.
./scripts/build-cross.sh --bin souveraine-sensord
NBIN="$CARGO_TARGET_DIR/aarch64-unknown-linux-gnu/release/souveraine-sensord"
file "$NBIN" | grep -q aarch64 || { echo "sensord is not aarch64" >&2; exit 1; }
cp "$NBIN" souveraine-sensord-aarch64
# secrets rail + machined: also hand-copied on the phone until now.
./scripts/build-cross.sh --features secrets --bin souveraine-secrets
./scripts/build-cross.sh --bin souveraine-machined
@ -147,7 +154,7 @@ jobs:
# Ship it on aarch64 only until the laptop side is sorted.
sha256sum souveraine-aarch64 souveraine-x86_64 \
souveraine-sessiond-aarch64 \
souveraine-sessiond-aarch64 souveraine-sensord-aarch64 \
souveraine-secrets-aarch64 souveraine-secrets-x86_64 \
souveraine-machined-aarch64 souveraine-machined-x86_64 \
> souveraine-binaries.sha256
@ -266,6 +273,8 @@ jobs:
if [ "$ARCH" = aarch64 ]; then
cp "souveraine-sessiond-$ARCH" "$PKG_WORK/souveraine-sessiond-binary"
cp packaging/souveraine-sessiond.service "$PKG_WORK/"
cp "souveraine-sensord-$ARCH" "$PKG_WORK/souveraine-sensord-binary"
cp packaging/souveraine-sensord.service "$PKG_WORK/"
fi
cp packaging/souveraine-verify-trail "$PKG_WORK/"
cp packaging/arch/PKGBUILD.prebuilt "$PKG_WORK/PKGBUILD"

View file

@ -206,6 +206,12 @@ name = "souveraine-sessiond"
path = "src/bin/souveraine-sessiond.rs"
required-features = ["sessiond"]
# The sensor reporter. No features: it speaks to sessiond over the socket like
# any other client and pulls in none of the lock/PAM/Wayland machinery.
[[bin]]
name = "souveraine-sensord"
path = "src/bin/souveraine-sensord.rs"
[profile.release]
opt-level = 3
lto = true

View file

@ -23,8 +23,9 @@ sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
# that arch and omits them otherwise, so package() picks them up conditionally
# rather than declaring them as sources for every arch.
if [ "$CARCH" = aarch64 ]; then
source+=('souveraine-sessiond-binary' 'souveraine-sessiond.service')
sha256sums+=('SKIP' 'SKIP')
source+=('souveraine-sessiond-binary' 'souveraine-sessiond.service'
'souveraine-sensord-binary' 'souveraine-sensord.service')
sha256sums+=('SKIP' 'SKIP' 'SKIP' 'SKIP')
fi
package() {
@ -41,6 +42,14 @@ package() {
install -Dm644 "$srcdir/souveraine-sessiond.service" \
"$pkgdir/usr/lib/systemd/user/souveraine-sessiond.service"
fi
# Sensor reporter, aarch64 only — the laptop has no iio-sensor-proxy
# sources worth reporting and no device state machine listening.
if [ -f "$srcdir/souveraine-sensord-binary" ]; then
install -Dm755 "$srcdir/souveraine-sensord-binary" \
"$pkgdir/usr/bin/souveraine-sensord"
install -Dm644 "$srcdir/souveraine-sensord.service" \
"$pkgdir/usr/lib/systemd/user/souveraine-sensord.service"
fi
# Secrets rail (user service, owns org.freedesktop.secrets) and machined
# (system service, machine identity). Both were hand-copied to the phone
# and owned by no package — same gap as sessiond.

View file

@ -0,0 +1,21 @@
[Unit]
Description=Souveraine sensor reporter (proximity, light, accelerometer)
# One reporter for every iio-sensor-proxy source. Replaces the per-sensor shell
# scripts (blueline-proximity-lock and the light/accel copies that were about to
# be written) — see DEVICE-STATE-MACHINE.md §10 and §12.
#
# A user unit, like sessiond: it talks to sessiond over
# $XDG_RUNTIME_DIR/souveraine/sessiond.sock and needs no privilege at all.
PartOf=graphical-session.target
After=souveraine-sessiond.service
[Service]
ExecStart=/usr/bin/souveraine-sensord
# The reporter must outlive every transient failure it can hit — a dead sensor
# stack, a restarting sessiond, monitor-sensor exiting. It handles those in
# process; this covers the ones it cannot.
Restart=always
RestartSec=5
[Install]
WantedBy=graphical-session.target

View file

@ -0,0 +1,308 @@
//! 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 = if line.trim_end().ends_with(": 0") || line.contains("near: 0") {
false
} else {
true
};
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 };
{
let mut guard = match last.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.insert(s.name, value.clone());
}
// 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);
}
}