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:
parent
4d652bbbc6
commit
83fab1a1b6
5 changed files with 356 additions and 3 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
21
packaging/souveraine-sensord.service
Normal file
21
packaging/souveraine-sensord.service
Normal 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
|
||||
308
src/bin/souveraine-sensord.rs
Normal file
308
src/bin/souveraine-sensord.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue