charge becomes a sensor source
ChargeEvidence was the rule violated four times in one type: a side door past the gate, the decider probing sysfs on the clock, the driver interpreting, and nothing leaving tick(). Its comment cited bearer — another instance of itself. SensorSource::Charge now enters through sensor_input like every other source, reported by sensord on a 30s poll off /sys/class/power_supply. conclude_charge lives on the machine as its one decision; source health, freshness and Absent/Down come from the gate for free. The 5s clock probe is gone, the driver type is gone, and sensord ships x86_64 too: the laptop has a battery.
This commit is contained in:
parent
4662fa1c58
commit
1336960ca5
9 changed files with 377 additions and 213 deletions
|
|
@ -243,12 +243,21 @@ jobs:
|
||||||
file "$B" | grep -q 'x86-64' || { echo "sessiond is not x86_64" >&2; exit 1; }
|
file "$B" | grep -q 'x86-64' || { echo "sessiond is not x86_64" >&2; exit 1; }
|
||||||
cp "$B" souveraine-sessiond-x86_64
|
cp "$B" souveraine-sessiond-x86_64
|
||||||
|
|
||||||
|
# sensord for x86_64 too. It stopped being phone-only when charge
|
||||||
|
# became a source: sessiond no longer probes the supplies itself, so
|
||||||
|
# a machine without the reporter has no charge evidence at all — and
|
||||||
|
# the laptop is a machine with a battery.
|
||||||
|
cargo build --release --bin souveraine-sensord
|
||||||
|
B="$CARGO_TARGET_DIR/release/souveraine-sensord"
|
||||||
|
file "$B" | grep -q 'x86-64' || { echo "sensord is not x86_64" >&2; exit 1; }
|
||||||
|
cp "$B" souveraine-sensord-x86_64
|
||||||
|
|
||||||
sha256sum souveraine-aarch64 souveraine-x86_64 \
|
sha256sum souveraine-aarch64 souveraine-x86_64 \
|
||||||
souveraine-sessiond-aarch64 souveraine-sensord-aarch64 \
|
souveraine-sessiond-aarch64 souveraine-sensord-aarch64 \
|
||||||
souveraine-usb-kvm-aarch64 \
|
souveraine-usb-kvm-aarch64 \
|
||||||
souveraine-secrets-aarch64 souveraine-secrets-x86_64 \
|
souveraine-secrets-aarch64 souveraine-secrets-x86_64 \
|
||||||
souveraine-machined-aarch64 souveraine-machined-x86_64 \
|
souveraine-machined-aarch64 souveraine-machined-x86_64 \
|
||||||
souveraine-sessiond-x86_64 \
|
souveraine-sessiond-x86_64 souveraine-sensord-x86_64 \
|
||||||
> souveraine-binaries.sha256
|
> souveraine-binaries.sha256
|
||||||
|
|
||||||
- name: Build UPower fork (per-arch install trees)
|
- name: Build UPower fork (per-arch install trees)
|
||||||
|
|
@ -395,14 +404,14 @@ jobs:
|
||||||
cp "souveraine-secrets-$ARCH" "$PKG_WORK/souveraine-secrets-binary"
|
cp "souveraine-secrets-$ARCH" "$PKG_WORK/souveraine-secrets-binary"
|
||||||
cp "souveraine-machined-$ARCH" "$PKG_WORK/souveraine-machined-binary"
|
cp "souveraine-machined-$ARCH" "$PKG_WORK/souveraine-machined-binary"
|
||||||
cp "souveraine-sessiond-$ARCH" "$PKG_WORK/souveraine-sessiond-binary"
|
cp "souveraine-sessiond-$ARCH" "$PKG_WORK/souveraine-sessiond-binary"
|
||||||
|
cp "souveraine-sensord-$ARCH" "$PKG_WORK/souveraine-sensord-binary"
|
||||||
cp packaging/souveraine.service packaging/souveraine-secrets.service \
|
cp packaging/souveraine.service packaging/souveraine-secrets.service \
|
||||||
packaging/org.freedesktop.secrets.service \
|
packaging/org.freedesktop.secrets.service \
|
||||||
packaging/souveraine-machined.service \
|
packaging/souveraine-machined.service \
|
||||||
packaging/souveraine-sessiond.service LICENSE "$PKG_WORK/"
|
packaging/souveraine-sessiond.service \
|
||||||
|
packaging/souveraine-sensord.service LICENSE "$PKG_WORK/"
|
||||||
# Phone-only reporters and surfaces remain aarch64-only.
|
# Phone-only reporters and surfaces remain aarch64-only.
|
||||||
if [ "$ARCH" = aarch64 ]; then
|
if [ "$ARCH" = aarch64 ]; then
|
||||||
cp "souveraine-sensord-$ARCH" "$PKG_WORK/souveraine-sensord-binary"
|
|
||||||
cp packaging/souveraine-sensord.service "$PKG_WORK/"
|
|
||||||
cp "souveraine-usb-kvm-$ARCH" "$PKG_WORK/souveraine-usb-kvm-binary"
|
cp "souveraine-usb-kvm-$ARCH" "$PKG_WORK/souveraine-usb-kvm-binary"
|
||||||
# The wry host. Guarded, and the PKGBUILD source line is removed
|
# The wry host. Guarded, and the PKGBUILD source line is removed
|
||||||
# when it is absent: it needs webkit2gtk in the aarch64 sysroot,
|
# when it is absent: it needs webkit2gtk in the aarch64 sysroot,
|
||||||
|
|
@ -429,6 +438,7 @@ jobs:
|
||||||
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine'
|
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine'
|
||||||
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-sessiond'
|
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-sessiond'
|
||||||
bsdtar -tf "$PKG" | grep -qx 'usr/lib/systemd/user/souveraine-sessiond.service'
|
bsdtar -tf "$PKG" | grep -qx 'usr/lib/systemd/user/souveraine-sessiond.service'
|
||||||
|
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-sensord'
|
||||||
bsdtar -tf "$PKG" | grep -qx 'etc/pam.d/souveraine-sessiond'
|
bsdtar -tf "$PKG" | grep -qx 'etc/pam.d/souveraine-sessiond'
|
||||||
if [ "$ARCH" = aarch64 ]; then
|
if [ "$ARCH" = aarch64 ]; then
|
||||||
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-usb-kvm'
|
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-usb-kvm'
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,18 @@ source=('souveraine-binary' 'souveraine.service'
|
||||||
'souveraine-secrets-binary' 'souveraine-secrets.service'
|
'souveraine-secrets-binary' 'souveraine-secrets.service'
|
||||||
'souveraine-machined-binary' 'souveraine-machined.service'
|
'souveraine-machined-binary' 'souveraine-machined.service'
|
||||||
'souveraine-sessiond-binary' 'souveraine-sessiond.service'
|
'souveraine-sessiond-binary' 'souveraine-sessiond.service'
|
||||||
|
'souveraine-sensord-binary' 'souveraine-sensord.service'
|
||||||
'souveraine-sessiond.pam'
|
'souveraine-sessiond.pam'
|
||||||
'souveraine-verify-trail' 'souveraine-button'
|
'souveraine-verify-trail' 'souveraine-button'
|
||||||
'souveraine-stepup.pam' 'org.souveraine.stepup.policy'
|
'souveraine-stepup.pam' 'org.souveraine.stepup.policy'
|
||||||
'LICENSE')
|
'LICENSE')
|
||||||
sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP'
|
sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP'
|
||||||
'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
|
'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP')
|
||||||
# sessiond is built and packaged for both supported architectures. Installing
|
# sessiond is built and packaged for both supported architectures. Installing
|
||||||
# its user unit does not enable or start it; activation remains target-owned.
|
# its user unit does not enable or start it; activation remains target-owned.
|
||||||
if [ "$CARCH" = aarch64 ]; then
|
if [ "$CARCH" = aarch64 ]; then
|
||||||
source+=('souveraine-sensord-binary' 'souveraine-sensord.service'
|
source+=('souveraine-usb-kvm-binary')
|
||||||
'souveraine-usb-kvm-binary')
|
sha256sums+=('SKIP')
|
||||||
sha256sums+=('SKIP' 'SKIP' 'SKIP')
|
|
||||||
depends+=('wayland' 'libx11' 'libxcursor' 'libxi' 'libxkbcommon' 'libxrandr')
|
depends+=('wayland' 'libx11' 'libxcursor' 'libxi' 'libxkbcommon' 'libxrandr')
|
||||||
# The wry host links the system webview, so it only cross-builds once
|
# The wry host links the system webview, so it only cross-builds once
|
||||||
# webkit2gtk is in the aarch64 sysroot. Declared only when CI actually
|
# webkit2gtk is in the aarch64 sysroot. Declared only when CI actually
|
||||||
|
|
@ -56,13 +56,16 @@ package() {
|
||||||
# had not been touched since (TASK-76).
|
# had not been touched since (TASK-76).
|
||||||
install -Dm644 "$srcdir/souveraine-sessiond.pam" \
|
install -Dm644 "$srcdir/souveraine-sessiond.pam" \
|
||||||
"$pkgdir/etc/pam.d/souveraine-sessiond"
|
"$pkgdir/etc/pam.d/souveraine-sessiond"
|
||||||
# Sensor reporter, aarch64 only — the laptop has no iio-sensor-proxy
|
# The wry host, aarch64 only (webkit2gtk in the sysroot is the gate).
|
||||||
# sources worth reporting and no device state machine listening.
|
|
||||||
if [ -f "$srcdir/souveraine-web-binary" ]; then
|
if [ -f "$srcdir/souveraine-web-binary" ]; then
|
||||||
install -Dm755 "$srcdir/souveraine-web-binary" \
|
install -Dm755 "$srcdir/souveraine-web-binary" \
|
||||||
"$pkgdir/usr/bin/souveraine-web"
|
"$pkgdir/usr/bin/souveraine-web"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Sensor reporter, both arches. It stopped being phone-only when charge
|
||||||
|
# became a source: sessiond never probes the supplies itself, so the
|
||||||
|
# reporter is the only thing that feeds charge evidence — and the laptop
|
||||||
|
# has a battery.
|
||||||
if [ -f "$srcdir/souveraine-sensord-binary" ]; then
|
if [ -f "$srcdir/souveraine-sensord-binary" ]; then
|
||||||
install -Dm755 "$srcdir/souveraine-sensord-binary" \
|
install -Dm755 "$srcdir/souveraine-sensord-binary" \
|
||||||
"$pkgdir/usr/bin/souveraine-sensord"
|
"$pkgdir/usr/bin/souveraine-sensord"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Souveraine sensor reporter (proximity, light, accelerometer)
|
Description=Souveraine sensor reporter (proximity, light, charge)
|
||||||
# One reporter for every iio-sensor-proxy source. Replaces the per-sensor shell
|
# 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
|
# scripts (blueline-proximity-lock and the light/accel copies that were about to
|
||||||
# be written) — see DEVICE-STATE-MACHINE.md §10 and §12.
|
# be written) — see DEVICE-STATE-MACHINE.md §10 and §12.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! souveraine-sensord — one reporter for every iio-sensor-proxy source.
|
//! souveraine-sensord — one reporter for every source the machine expects.
|
||||||
//!
|
//!
|
||||||
//! REPORTER, NOT AN AUTHORITY. It reads sensors and tells sessiond. It decides
|
//! REPORTER, NOT AN AUTHORITY. It reads sensors and tells sessiond. It decides
|
||||||
//! nothing, actuates nothing, and reads no lock state. Every decision belongs
|
//! nothing, actuates nothing, and reads no lock state. Every decision belongs
|
||||||
|
|
@ -58,6 +58,17 @@ const SESSIOND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
/// Backoff when `monitor-sensor` exits or the proxy has no sensors yet.
|
/// Backoff when `monitor-sensor` exits or the proxy has no sensors yet.
|
||||||
const RESPAWN_DELAY: Duration = Duration::from_secs(5);
|
const RESPAWN_DELAY: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How often the charge source reads the supplies.
|
||||||
|
///
|
||||||
|
/// The battery moves on a minutes scale; charging decisions (the resting
|
||||||
|
/// hysteresis of a topped-up pack) move slower still. A 30 s cadence is an
|
||||||
|
/// order of magnitude faster than anything charge does, and it lines the
|
||||||
|
/// reading up with the keepalive so the machine hears from the source on one
|
||||||
|
/// schedule. The keepalive re-sends the last reading between polls, which is
|
||||||
|
/// what keeps the source Live in the machine's health table even when the
|
||||||
|
/// value itself never changes.
|
||||||
|
const CHARGE_POLL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// The last reading we sent per source, in the exact JSON shape sessiond's
|
/// The last reading we sent per source, in the exact JSON shape sessiond's
|
||||||
/// `SensorInput` expects. Shared with the keepalive thread.
|
/// `SensorInput` expects. Shared with the keepalive thread.
|
||||||
type LastSeen = Arc<Mutex<HashMap<&'static str, serde_json::Value>>>;
|
type LastSeen = Arc<Mutex<HashMap<&'static str, serde_json::Value>>>;
|
||||||
|
|
@ -340,6 +351,90 @@ fn spawn_keepalive(sock: PathBuf, last: LastSeen) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_trimmed(dir: &std::path::Path, attr: &str) -> Option<String> {
|
||||||
|
std::fs::read_to_string(dir.join(attr))
|
||||||
|
.ok()
|
||||||
|
.map(|v| v.trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `/sys/class/power_supply` into the wire shape of `SensorValue::Charge`.
|
||||||
|
///
|
||||||
|
/// Classify each node by its own `type` attribute — device names are not the
|
||||||
|
/// contract (blueline's are `qcom-battery` and `pmi8998-charger`; another
|
||||||
|
/// body differs). Every read is independent: one missing attribute degrades
|
||||||
|
/// one field, never the whole probe.
|
||||||
|
fn probe_charge() -> serde_json::Value {
|
||||||
|
let mut fields = serde_json::json!({
|
||||||
|
"plugged": serde_json::Value::Null,
|
||||||
|
"status": serde_json::Value::Null,
|
||||||
|
"charge_type": serde_json::Value::Null,
|
||||||
|
"capacity": serde_json::Value::Null,
|
||||||
|
});
|
||||||
|
let Ok(entries) = std::fs::read_dir("/sys/class/power_supply") else {
|
||||||
|
return fields;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let dir = entry.path();
|
||||||
|
let Some(kind) = std::fs::read_to_string(dir.join("type")).ok() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if kind.trim() == "Battery" {
|
||||||
|
if let Some(status) = read_trimmed(&dir, "status") {
|
||||||
|
fields["status"] = status.into();
|
||||||
|
}
|
||||||
|
if let Ok(capacity) = std::fs::read_to_string(dir.join("capacity")) {
|
||||||
|
if let Ok(v) = capacity.trim().parse::<u8>() {
|
||||||
|
fields["capacity"] = v.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Any non-battery supply asserting online counts as plugged —
|
||||||
|
// USB, mains, wireless all mean the same thing to policy.
|
||||||
|
if read_trimmed(&dir, "online").as_deref() == Some("1") {
|
||||||
|
fields["plugged"] = serde_json::Value::Bool(true);
|
||||||
|
} else if fields["plugged"].is_null() {
|
||||||
|
fields["plugged"] = serde_json::Value::Bool(false);
|
||||||
|
}
|
||||||
|
// The charger carries charge_type; the first real answer wins.
|
||||||
|
// "Unknown"/"N/A" are the driver's silence, not a reading.
|
||||||
|
if fields["charge_type"].is_null() {
|
||||||
|
if let Some(ct) = read_trimmed(&dir, "charge_type") {
|
||||||
|
if ct != "Unknown" && ct != "N/A" {
|
||||||
|
fields["charge_type"] = ct.into();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::json!({ "charge": fields })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the supplies on a fixed cadence and report on change.
|
||||||
|
///
|
||||||
|
/// Charge is not a monitor-sensor source: it has no change lines, so it gets
|
||||||
|
/// a reader of its own instead of a parser. The last reading lands in the
|
||||||
|
/// same map the keepalive serves, so sessiond hears from the source on the
|
||||||
|
/// same schedule as proximity and light and its health table stays honest.
|
||||||
|
fn spawn_charge_poller(sock: PathBuf, last: LastSeen) {
|
||||||
|
std::thread::spawn(move || loop {
|
||||||
|
std::thread::sleep(CHARGE_POLL);
|
||||||
|
let value = probe_charge();
|
||||||
|
let unchanged = {
|
||||||
|
let mut guard = match last.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(p) => p.into_inner(),
|
||||||
|
};
|
||||||
|
let same = guard.get("charge") == Some(&value);
|
||||||
|
guard.insert("charge", value.clone());
|
||||||
|
same
|
||||||
|
};
|
||||||
|
if unchanged {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
report(&sock, "charge", &value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_monitor() -> std::io::Result<Child> {
|
fn spawn_monitor() -> std::io::Result<Child> {
|
||||||
let mut cmd = Command::new("monitor-sensor");
|
let mut cmd = Command::new("monitor-sensor");
|
||||||
for s in SOURCES {
|
for s in SOURCES {
|
||||||
|
|
@ -357,6 +452,7 @@ fn main() {
|
||||||
// like a fresh change.
|
// like a fresh change.
|
||||||
let parser: Parser = Arc::new(Mutex::new(ParserState::default()));
|
let parser: Parser = Arc::new(Mutex::new(ParserState::default()));
|
||||||
spawn_keepalive(sock.clone(), Arc::clone(&last));
|
spawn_keepalive(sock.clone(), Arc::clone(&last));
|
||||||
|
spawn_charge_poller(sock.clone(), Arc::clone(&last));
|
||||||
|
|
||||||
// One monitor-sensor for every source, respawned if it dies. Restarting the
|
// 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
|
// process is the recovery path for a sensor stack that came back after an
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
//! Charging as machine-owned evidence (TASK-33 §2).
|
|
||||||
//!
|
|
||||||
//! Until this module every surface read UPower field-by-field and rendered
|
|
||||||
//! whatever it said, so the phone showed things true per-field and wrong as
|
|
||||||
//! a sentence — 100%, charger terminated, and the lock screen counting down
|
|
||||||
//! from 94% with the cable in. That countdown is charge-termination
|
|
||||||
//! hysteresis, the charger resting between top-ups, and nothing in the stack
|
|
||||||
//! could say so because nothing owned the question.
|
|
||||||
//!
|
|
||||||
//! The probe reads sysfs directly rather than asking UPower: the fork
|
|
||||||
//! already walks the same supplier link for `charge_type`, and the machine
|
|
||||||
//! is the one reader every surface will project. One decision, made once,
|
|
||||||
//! in `conclusion()` — surfaces render it; they do not re-derive it.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// What the kernel says the battery is doing, verbatim.
|
|
||||||
///
|
|
||||||
/// Kept as the kernel's own strings rather than re-enumed: a value this
|
|
||||||
/// crate does not recognise must survive to the readout untouched, because
|
|
||||||
/// "the kernel said something new" is itself the evidence.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct ChargeEvidence {
|
|
||||||
/// A non-battery supply reports online=1. `None` when no supply node was
|
|
||||||
/// readable at all — absence is not "unplugged".
|
|
||||||
pub plugged: Option<bool>,
|
|
||||||
/// The fuel gauge's `status`: "Charging", "Discharging", "Not charging",
|
|
||||||
/// "Full", or whatever the driver said.
|
|
||||||
pub status: Option<String>,
|
|
||||||
/// The charger's `charge_type` — "Fast", "Slow", "Trickle" on blueline's
|
|
||||||
/// pmi8998. The fuel gauge has no such attribute; like the fork, we read
|
|
||||||
/// it off the charger side.
|
|
||||||
pub charge_type: Option<String>,
|
|
||||||
/// Fuel gauge percentage, 0–100.
|
|
||||||
pub capacity: Option<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChargeEvidence {
|
|
||||||
/// The one decision surfaces render instead of guessing.
|
|
||||||
///
|
|
||||||
/// Pure function of the evidence — no clock, no I/O — so the mapping is
|
|
||||||
/// testable and the same on every tick. "Resting" is the state nothing
|
|
||||||
/// else could name: plugged in, not charging, not full. It is the normal
|
|
||||||
/// hysteresis rest of a topped-up pack and is never a fault (TASK-33's
|
|
||||||
/// do-not).
|
|
||||||
pub fn conclusion(&self) -> &'static str {
|
|
||||||
let status = self.status.as_deref();
|
|
||||||
match (self.plugged, status) {
|
|
||||||
(_, Some("Full")) => "charged",
|
|
||||||
(Some(true), Some("Charging")) => match self.charge_type.as_deref() {
|
|
||||||
Some("Fast") => "charging_fast",
|
|
||||||
Some("Trickle" | "Slow") => "charging_slow",
|
|
||||||
_ => "charging",
|
|
||||||
},
|
|
||||||
(Some(true), Some("Not charging")) => "resting",
|
|
||||||
(Some(true), Some("Discharging")) => "resting",
|
|
||||||
(Some(false), _) => "on_battery",
|
|
||||||
(_, Some("Discharging")) => "on_battery",
|
|
||||||
_ => "unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_json(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"plugged": self.plugged,
|
|
||||||
"status": self.status,
|
|
||||||
"charge_type": self.charge_type,
|
|
||||||
"capacity": self.capacity,
|
|
||||||
"conclusion": self.conclusion(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_trimmed(dir: &std::path::Path, attr: &str) -> Option<String> {
|
|
||||||
std::fs::read_to_string(dir.join(attr))
|
|
||||||
.ok()
|
|
||||||
.map(|v| v.trim().to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk `/sys/class/power_supply`, classify by each node's own `type`.
|
|
||||||
///
|
|
||||||
/// Device names are not the contract (blueline's are `qcom-battery` and
|
|
||||||
/// `pmi8998-charger`; another body differs) — the `type` attribute is.
|
|
||||||
/// Every read is independent: one missing attribute degrades one field,
|
|
||||||
/// never the whole probe.
|
|
||||||
pub fn probe() -> ChargeEvidence {
|
|
||||||
let mut ev = ChargeEvidence {
|
|
||||||
plugged: None,
|
|
||||||
status: None,
|
|
||||||
charge_type: None,
|
|
||||||
capacity: None,
|
|
||||||
};
|
|
||||||
let Ok(entries) = std::fs::read_dir("/sys/class/power_supply") else {
|
|
||||||
return ev;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let dir = entry.path();
|
|
||||||
let Some(kind) = read_trimmed(&dir, "type") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if kind == "Battery" {
|
|
||||||
ev.status = read_trimmed(&dir, "status");
|
|
||||||
ev.capacity = read_trimmed(&dir, "capacity")
|
|
||||||
.and_then(|v| v.parse::<u8>().ok());
|
|
||||||
} else {
|
|
||||||
// Any non-battery supply asserting online counts as plugged —
|
|
||||||
// USB, mains, wireless all mean the same thing to policy.
|
|
||||||
if read_trimmed(&dir, "online").as_deref() == Some("1") {
|
|
||||||
ev.plugged = Some(true);
|
|
||||||
} else if ev.plugged.is_none() {
|
|
||||||
ev.plugged = Some(false);
|
|
||||||
}
|
|
||||||
// The charger carries charge_type; the first real answer wins.
|
|
||||||
// "Unknown"/"N/A" are the driver's silence, not a reading.
|
|
||||||
if ev.charge_type.is_none() {
|
|
||||||
if let Some(ct) = read_trimmed(&dir, "charge_type") {
|
|
||||||
if ct != "Unknown" && ct != "N/A" {
|
|
||||||
ev.charge_type = Some(ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ev
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn ev(plugged: Option<bool>, status: Option<&str>, ct: Option<&str>) -> ChargeEvidence {
|
|
||||||
ChargeEvidence {
|
|
||||||
plugged,
|
|
||||||
status: status.map(str::to_owned),
|
|
||||||
charge_type: ct.map(str::to_owned),
|
|
||||||
capacity: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_conclusion_is_one_decision() {
|
|
||||||
assert_eq!(ev(Some(true), Some("Full"), None).conclusion(), "charged");
|
|
||||||
assert_eq!(
|
|
||||||
ev(Some(true), Some("Charging"), Some("Fast")).conclusion(),
|
|
||||||
"charging_fast"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ev(Some(true), Some("Charging"), Some("Trickle")).conclusion(),
|
|
||||||
"charging_slow"
|
|
||||||
);
|
|
||||||
// The state nothing else could name: cable in, pack topped up,
|
|
||||||
// charger resting between hysteresis top-ups. Not a fault.
|
|
||||||
assert_eq!(
|
|
||||||
ev(Some(true), Some("Not charging"), None).conclusion(),
|
|
||||||
"resting"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
ev(Some(true), Some("Discharging"), None).conclusion(),
|
|
||||||
"resting",
|
|
||||||
"discharging while plugged is the resting state, never an alarm"
|
|
||||||
);
|
|
||||||
assert_eq!(ev(Some(false), None, None).conclusion(), "on_battery");
|
|
||||||
assert_eq!(ev(None, None, None).conclusion(), "unknown");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -21,10 +21,9 @@ use std::time::{Duration, Instant};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::sessiond::bearer::{Bearer, BearerEvidence};
|
use crate::sessiond::bearer::{Bearer, BearerEvidence};
|
||||||
use crate::sessiond::charge::ChargeEvidence;
|
|
||||||
use crate::sessiond::protocol::{
|
use crate::sessiond::protocol::{
|
||||||
Button, ButtonEdge, ButtonGesture, InputTrigger, PowerVerb, SensorSource, SensorValue,
|
Button, ButtonEdge, ButtonGesture, ChargeFields, InputTrigger, PowerVerb, SensorSource,
|
||||||
TouchGesture, UsbMode,
|
SensorValue, TouchGesture, UsbMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// How long a locked, lit panel waits for input before it blanks.
|
/// How long a locked, lit panel waits for input before it blanks.
|
||||||
|
|
@ -89,18 +88,23 @@ pub const SOURCE_EXPECTED_WITHIN: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
/// Which sources a reporter is serving on this device *right now*.
|
/// Which sources a reporter is serving on this device *right now*.
|
||||||
///
|
///
|
||||||
/// Exactly what `souveraine-sensord` reports (`SOURCES` in its `main`):
|
/// Exactly what `souveraine-sensord` reports: proximity and light off
|
||||||
/// proximity and light. `Touch` has no reporter either, so it stays `Unknown`
|
/// iio-sensor-proxy, charge off `/sys/class/power_supply`. `Touch` has no
|
||||||
/// and silent per §10. A source listed here with nothing behind it
|
/// reporter either, so it stays `Unknown` and silent per §10. A source
|
||||||
/// manufactures a permanent false alarm — the failure this mechanism exists to
|
/// listed here with nothing behind it manufactures a permanent false alarm
|
||||||
/// avoid in the other direction — and it did: accelerometer stayed here after
|
/// — the failure this mechanism exists to avoid in the other direction —
|
||||||
/// `6b67512` (2026-08-04) dropped the claim, so every boot went `Absent` at
|
/// and it did: accelerometer stayed here after `6b67512` (2026-08-04)
|
||||||
/// 300 s and `sensors_degraded` read true on 3074 consecutive snapshots.
|
/// dropped the claim, so every boot went `Absent` at 300 s and
|
||||||
|
/// `sensors_degraded` read true on 3074 consecutive snapshots.
|
||||||
///
|
///
|
||||||
/// The accelerometer is wanted and deliberately unclaimed: a continuous
|
/// The accelerometer is wanted and deliberately unclaimed: a continuous
|
||||||
/// iio-sensor-proxy claim cost 14% of a core (TASK-15). It comes back through
|
/// iio-sensor-proxy claim cost 14% of a core (TASK-15). It comes back through
|
||||||
/// TASK-36's SLPI batching, and this list is where it returns.
|
/// TASK-36's SLPI batching, and this list is where it returns.
|
||||||
pub const EXPECTED_SOURCES: &[SensorSource] = &[SensorSource::Proximity, SensorSource::Light];
|
pub const EXPECTED_SOURCES: &[SensorSource] = &[
|
||||||
|
SensorSource::Proximity,
|
||||||
|
SensorSource::Light,
|
||||||
|
SensorSource::Charge,
|
||||||
|
];
|
||||||
|
|
||||||
/// How long proximity must read `near` before the machine believes it.
|
/// How long proximity must read `near` before the machine believes it.
|
||||||
///
|
///
|
||||||
|
|
@ -494,6 +498,7 @@ pub struct EvidenceSeen {
|
||||||
pub accel: Option<Instant>,
|
pub accel: Option<Instant>,
|
||||||
pub light: Option<Instant>,
|
pub light: Option<Instant>,
|
||||||
pub touch: Option<Instant>,
|
pub touch: Option<Instant>,
|
||||||
|
pub charge: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EvidenceSeen {
|
impl EvidenceSeen {
|
||||||
|
|
@ -506,6 +511,7 @@ impl EvidenceSeen {
|
||||||
SensorSource::Accelerometer => self.accel,
|
SensorSource::Accelerometer => self.accel,
|
||||||
SensorSource::Light => self.light,
|
SensorSource::Light => self.light,
|
||||||
SensorSource::Touch => self.touch,
|
SensorSource::Touch => self.touch,
|
||||||
|
SensorSource::Charge => self.charge,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -576,6 +582,7 @@ pub struct SourceHealthTable {
|
||||||
pub accel: SourceHealth,
|
pub accel: SourceHealth,
|
||||||
pub light: SourceHealth,
|
pub light: SourceHealth,
|
||||||
pub touch: SourceHealth,
|
pub touch: SourceHealth,
|
||||||
|
pub charge: SourceHealth,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourceHealthTable {
|
impl SourceHealthTable {
|
||||||
|
|
@ -585,6 +592,7 @@ impl SourceHealthTable {
|
||||||
SensorSource::Accelerometer => &mut self.accel,
|
SensorSource::Accelerometer => &mut self.accel,
|
||||||
SensorSource::Light => &mut self.light,
|
SensorSource::Light => &mut self.light,
|
||||||
SensorSource::Touch => &mut self.touch,
|
SensorSource::Touch => &mut self.touch,
|
||||||
|
SensorSource::Charge => &mut self.charge,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -595,9 +603,15 @@ impl SourceHealthTable {
|
||||||
/// a decision taken on absent evidence is no sounder than one taken on
|
/// a decision taken on absent evidence is no sounder than one taken on
|
||||||
/// evidence that stopped.
|
/// evidence that stopped.
|
||||||
pub fn any_down(&self) -> bool {
|
pub fn any_down(&self) -> bool {
|
||||||
[self.proximity, self.accel, self.light, self.touch]
|
[
|
||||||
.iter()
|
self.proximity,
|
||||||
.any(|h| matches!(h, SourceHealth::Down | SourceHealth::Absent))
|
self.accel,
|
||||||
|
self.light,
|
||||||
|
self.touch,
|
||||||
|
self.charge,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|h| matches!(h, SourceHealth::Down | SourceHealth::Absent))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_json(&self) -> serde_json::Value {
|
pub fn as_json(&self) -> serde_json::Value {
|
||||||
|
|
@ -606,6 +620,7 @@ impl SourceHealthTable {
|
||||||
"accel": self.accel.as_str(),
|
"accel": self.accel.as_str(),
|
||||||
"light": self.light.as_str(),
|
"light": self.light.as_str(),
|
||||||
"touch": self.touch.as_str(),
|
"touch": self.touch.as_str(),
|
||||||
|
"charge": self.charge.as_str(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1546,12 +1561,12 @@ pub struct DeviceStateMachine {
|
||||||
/// daemon and never inferred here — the same contract `panel_on` has with
|
/// daemon and never inferred here — the same contract `panel_on` has with
|
||||||
/// the DPMS executor.
|
/// the DPMS executor.
|
||||||
pub bearer: BearerEvidence,
|
pub bearer: BearerEvidence,
|
||||||
/// Charge evidence, refreshed by the daemon's probe — the last raw feed
|
/// Charge evidence, as reported by sensord through `sensor_input`. The
|
||||||
/// taken off the glass (TASK-33). Never inferred here, same contract as
|
/// machine never reads the supplies itself: a decider that also probes is
|
||||||
/// `bearer`.
|
/// one refactor away from being a driver (DEVICE-STATE-MACHINE.md §10).
|
||||||
pub charge: ChargeEvidence,
|
pub charge: ChargeFields,
|
||||||
/// The conclusion last recorded, so the trail carries the edge rather
|
/// The conclusion last recorded, so the trail carries the edge rather
|
||||||
/// than one entry per probe.
|
/// than one entry per report.
|
||||||
charge_conclusion: Option<&'static str>,
|
charge_conclusion: Option<&'static str>,
|
||||||
/// The bearer the machine has actually acted on, and when the current
|
/// The bearer the machine has actually acted on, and when the current
|
||||||
/// candidate first differed from it.
|
/// candidate first differed from it.
|
||||||
|
|
@ -1650,7 +1665,7 @@ impl DeviceStateMachine {
|
||||||
// and a setting that reverts on reboot is not a setting.
|
// and a setting that reverts on reboot is not a setting.
|
||||||
policy: DeviceStatePolicy::load(),
|
policy: DeviceStatePolicy::load(),
|
||||||
bearer: BearerEvidence::default(),
|
bearer: BearerEvidence::default(),
|
||||||
charge: ChargeEvidence {
|
charge: ChargeFields {
|
||||||
plugged: None,
|
plugged: None,
|
||||||
status: None,
|
status: None,
|
||||||
charge_type: None,
|
charge_type: None,
|
||||||
|
|
@ -2183,6 +2198,7 @@ impl DeviceStateMachine {
|
||||||
(SensorSource::Accelerometer, self.evidence_seen.accel),
|
(SensorSource::Accelerometer, self.evidence_seen.accel),
|
||||||
(SensorSource::Light, self.evidence_seen.light),
|
(SensorSource::Light, self.evidence_seen.light),
|
||||||
(SensorSource::Touch, self.evidence_seen.touch),
|
(SensorSource::Touch, self.evidence_seen.touch),
|
||||||
|
(SensorSource::Charge, self.evidence_seen.charge),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut newly_down: Vec<&str> = Vec::new();
|
let mut newly_down: Vec<&str> = Vec::new();
|
||||||
|
|
@ -2272,14 +2288,18 @@ impl DeviceStateMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Charge evidence in, conclusion edge out. The trail hears about a
|
/// Charge evidence in, conclusion edge out. The trail hears about a
|
||||||
/// change once, not once per probe — same rule as the bearer's.
|
/// change once, not once per report — same rule as the bearer's.
|
||||||
pub fn note_charge(&mut self, evidence: ChargeEvidence) {
|
///
|
||||||
|
/// Ingress only, and only through `sensor_input`: the reporter reads the
|
||||||
|
/// supplies, the machine interprets. The reading arrives with the same
|
||||||
|
/// freshness stamp and health accounting as any other source.
|
||||||
|
pub fn note_charge(&mut self, evidence: ChargeFields) {
|
||||||
self.charge = evidence;
|
self.charge = evidence;
|
||||||
let conclusion = self.charge.conclusion();
|
let conclusion = Self::conclude_charge(&self.charge);
|
||||||
if self.charge_conclusion != Some(conclusion) {
|
if self.charge_conclusion != Some(conclusion) {
|
||||||
let previous = self.charge_conclusion;
|
let previous = self.charge_conclusion;
|
||||||
self.charge_conclusion = Some(conclusion);
|
self.charge_conclusion = Some(conclusion);
|
||||||
let mut inputs = self.charge.as_json();
|
let mut inputs = self.charge_json();
|
||||||
inputs["previous_conclusion"] =
|
inputs["previous_conclusion"] =
|
||||||
previous.map_or(serde_json::Value::Null, |p| p.into());
|
previous.map_or(serde_json::Value::Null, |p| p.into());
|
||||||
self.record_decision(
|
self.record_decision(
|
||||||
|
|
@ -2290,6 +2310,41 @@ impl DeviceStateMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one decision surfaces render instead of guessing.
|
||||||
|
///
|
||||||
|
/// Pure function of the evidence — no clock, no I/O — so the mapping is
|
||||||
|
/// testable and the same on every report. Interpretation may consume
|
||||||
|
/// evidence but never a driver, so it lives on the machine, not in the
|
||||||
|
/// reporter's type. "Resting" is the state nothing else could name:
|
||||||
|
/// plugged in, not charging, not full. It is the normal hysteresis rest
|
||||||
|
/// of a topped-up pack and is never a fault (TASK-33's do-not).
|
||||||
|
pub fn conclude_charge(evidence: &ChargeFields) -> &'static str {
|
||||||
|
let status = evidence.status.as_deref();
|
||||||
|
match (evidence.plugged, status) {
|
||||||
|
(_, Some("Full")) => "charged",
|
||||||
|
(Some(true), Some("Charging")) => match evidence.charge_type.as_deref() {
|
||||||
|
Some("Fast") => "charging_fast",
|
||||||
|
Some("Trickle" | "Slow") => "charging_slow",
|
||||||
|
_ => "charging",
|
||||||
|
},
|
||||||
|
(Some(true), Some("Not charging")) => "resting",
|
||||||
|
(Some(true), Some("Discharging")) => "resting",
|
||||||
|
(Some(false), _) => "on_battery",
|
||||||
|
(_, Some("Discharging")) => "on_battery",
|
||||||
|
_ => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn charge_json(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"plugged": self.charge.plugged,
|
||||||
|
"status": self.charge.status,
|
||||||
|
"charge_type": self.charge.charge_type,
|
||||||
|
"capacity": self.charge.capacity,
|
||||||
|
"conclusion": Self::conclude_charge(&self.charge),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Decide the bearer posture, subject to the settling window.
|
/// Decide the bearer posture, subject to the settling window.
|
||||||
///
|
///
|
||||||
/// Returns at most one `PreferLink` and one `PinTunnelUnderlay`, and only
|
/// Returns at most one `PreferLink` and one `PinTunnelUnderlay`, and only
|
||||||
|
|
@ -2389,6 +2444,7 @@ impl DeviceStateMachine {
|
||||||
SensorSource::Accelerometer => self.evidence_seen.accel = Some(now),
|
SensorSource::Accelerometer => self.evidence_seen.accel = Some(now),
|
||||||
SensorSource::Light => self.evidence_seen.light = Some(now),
|
SensorSource::Light => self.evidence_seen.light = Some(now),
|
||||||
SensorSource::Touch => self.evidence_seen.touch = Some(now),
|
SensorSource::Touch => self.evidence_seen.touch = Some(now),
|
||||||
|
SensorSource::Charge => self.evidence_seen.charge = Some(now),
|
||||||
}
|
}
|
||||||
|
|
||||||
let health = self.source_health.get_mut(source);
|
let health = self.source_health.get_mut(source);
|
||||||
|
|
@ -3217,6 +3273,8 @@ impl DeviceStateMachine {
|
||||||
SensorSource::Touch => {
|
SensorSource::Touch => {
|
||||||
SensorValue::Active(self.sensor_evidence.touch_active)
|
SensorValue::Active(self.sensor_evidence.touch_active)
|
||||||
}
|
}
|
||||||
|
// Charge is ingested by the gate handler, never here.
|
||||||
|
SensorSource::Charge => unreachable!(),
|
||||||
},
|
},
|
||||||
confidence,
|
confidence,
|
||||||
},
|
},
|
||||||
|
|
@ -3348,6 +3406,7 @@ impl DeviceStateMachine {
|
||||||
"accel": self.is_fresh(self.evidence_seen.accel),
|
"accel": self.is_fresh(self.evidence_seen.accel),
|
||||||
"light": self.is_fresh(self.evidence_seen.light),
|
"light": self.is_fresh(self.evidence_seen.light),
|
||||||
"touch": self.is_fresh(self.evidence_seen.touch),
|
"touch": self.is_fresh(self.evidence_seen.touch),
|
||||||
|
"charge": self.is_fresh(self.evidence_seen.charge),
|
||||||
},
|
},
|
||||||
// Seconds since each source last reported; null means never.
|
// Seconds since each source last reported; null means never.
|
||||||
// `fresh` is a threshold answer, this is the raw number — a page
|
// `fresh` is a threshold answer, this is the raw number — a page
|
||||||
|
|
@ -3357,15 +3416,16 @@ impl DeviceStateMachine {
|
||||||
"accel": Self::secs_since(self.evidence_seen.accel),
|
"accel": Self::secs_since(self.evidence_seen.accel),
|
||||||
"light": Self::secs_since(self.evidence_seen.light),
|
"light": Self::secs_since(self.evidence_seen.light),
|
||||||
"touch": Self::secs_since(self.evidence_seen.touch),
|
"touch": Self::secs_since(self.evidence_seen.touch),
|
||||||
|
"charge": Self::secs_since(self.evidence_seen.charge),
|
||||||
},
|
},
|
||||||
// Health is the other axis: `fresh` says whether the reading may
|
// Health is the other axis: `fresh` says whether the reading may
|
||||||
// be believed, `health` says whether the source is there at all.
|
// be believed, `health` says whether the source is there at all.
|
||||||
"sensor_health": self.source_health.as_json(),
|
"sensor_health": self.source_health.as_json(),
|
||||||
"sensors_degraded": self.source_health.any_down(),
|
"sensors_degraded": self.source_health.any_down(),
|
||||||
// Charging as the machine's own evidence — the last raw feed off
|
// Charging as the machine's own evidence — reported by sensord,
|
||||||
// the glass (TASK-33). Surfaces render `conclusion`; they do not
|
// interpreted here (TASK-33). Surfaces render `conclusion`; they
|
||||||
// re-derive it.
|
// do not re-derive it.
|
||||||
"charge": self.charge.as_json(),
|
"charge": self.charge_json(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4870,6 +4930,7 @@ mod tests {
|
||||||
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
|
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
|
||||||
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
|
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
|
||||||
assert_eq!(sm.source_health.light, SourceHealth::Absent);
|
assert_eq!(sm.source_health.light, SourceHealth::Absent);
|
||||||
|
assert_eq!(sm.source_health.charge, SourceHealth::Absent);
|
||||||
assert!(
|
assert!(
|
||||||
sm.source_health.any_down(),
|
sm.source_health.any_down(),
|
||||||
"absent evidence is degraded evidence"
|
"absent evidence is degraded evidence"
|
||||||
|
|
@ -4890,7 +4951,7 @@ mod tests {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
|
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(errors.len(), 2, "one entry per expected source, once");
|
assert_eq!(errors.len(), 3, "one entry per expected source, once");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -4906,7 +4967,7 @@ mod tests {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
|
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
|
||||||
.count();
|
.count();
|
||||||
assert_eq!(errors, 2, "two expected sources, one entry each");
|
assert_eq!(errors, 3, "three expected sources, one entry each");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -5499,4 +5560,125 @@ mod tests {
|
||||||
"pinned an underlay for a tunnel the user switched off: {a:?}"
|
"pinned an underlay for a tunnel the user switched off: {a:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn charge_fields(
|
||||||
|
plugged: Option<bool>,
|
||||||
|
status: Option<&str>,
|
||||||
|
charge_type: Option<&str>,
|
||||||
|
) -> ChargeFields {
|
||||||
|
ChargeFields {
|
||||||
|
plugged,
|
||||||
|
status: status.map(str::to_owned),
|
||||||
|
charge_type: charge_type.map(str::to_owned),
|
||||||
|
capacity: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_conclusion_is_one_decision() {
|
||||||
|
// Moved here with the interpretation: it lives on the machine that
|
||||||
|
// consumes the evidence, never in the reporter's type.
|
||||||
|
assert_eq!(
|
||||||
|
DeviceStateMachine::conclude_charge(&charge_fields(Some(true), Some("Full"), None)),
|
||||||
|
"charged"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DeviceStateMachine::conclude_charge(&charge_fields(
|
||||||
|
Some(true),
|
||||||
|
Some("Charging"),
|
||||||
|
Some("Fast")
|
||||||
|
)),
|
||||||
|
"charging_fast"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DeviceStateMachine::conclude_charge(&charge_fields(
|
||||||
|
Some(true),
|
||||||
|
Some("Charging"),
|
||||||
|
Some("Trickle")
|
||||||
|
)),
|
||||||
|
"charging_slow"
|
||||||
|
);
|
||||||
|
// The state nothing else could name: cable in, pack topped up,
|
||||||
|
// charger resting between hysteresis top-ups. Not a fault.
|
||||||
|
assert_eq!(
|
||||||
|
DeviceStateMachine::conclude_charge(&charge_fields(
|
||||||
|
Some(true),
|
||||||
|
Some("Not charging"),
|
||||||
|
None
|
||||||
|
)),
|
||||||
|
"resting"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DeviceStateMachine::conclude_charge(&charge_fields(
|
||||||
|
Some(true),
|
||||||
|
Some("Discharging"),
|
||||||
|
None
|
||||||
|
)),
|
||||||
|
"resting",
|
||||||
|
"discharging while plugged is the resting state, never an alarm"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DeviceStateMachine::conclude_charge(&charge_fields(Some(false), None, None)),
|
||||||
|
"on_battery"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DeviceStateMachine::conclude_charge(&charge_fields(None, None, None)),
|
||||||
|
"unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn charge_enters_through_the_sensor_gate_and_gets_the_same_health() {
|
||||||
|
// The reading arrives like any other source's: evidence in, freshness
|
||||||
|
// stamped, health Live — and only the machine's conclusion on the
|
||||||
|
// wire, never the reporter's.
|
||||||
|
let mut sm = DeviceStateMachine::new();
|
||||||
|
sm.mark_evidence_seen_at(SensorSource::Charge, Instant::now());
|
||||||
|
assert_eq!(
|
||||||
|
sm.source_health.get_mut(SensorSource::Charge),
|
||||||
|
&mut SourceHealth::Live
|
||||||
|
);
|
||||||
|
assert!(sm.evidence_seen.charge.is_some());
|
||||||
|
|
||||||
|
sm.note_charge(charge_fields(Some(true), Some("Charging"), Some("Fast")));
|
||||||
|
let ipc = sm.to_ipc_json();
|
||||||
|
assert_eq!(ipc["charge"]["conclusion"], "charging_fast");
|
||||||
|
assert_eq!(ipc["charge"]["status"], "Charging");
|
||||||
|
assert_eq!(ipc["sensor_health"]["charge"], "live");
|
||||||
|
assert_eq!(ipc["evidence_last_seen_secs_ago"]["charge"].is_number(), true);
|
||||||
|
assert_eq!(ipc["sensors_degraded"], false);
|
||||||
|
|
||||||
|
// A conclusion edge writes one trail entry, not one per report — the
|
||||||
|
// keepalive re-sends the identical reading and must not repeat it.
|
||||||
|
sm.note_charge(charge_fields(Some(true), Some("Charging"), Some("Fast")));
|
||||||
|
let conclusion_edges: Vec<_> = sm
|
||||||
|
.forensic
|
||||||
|
.recent(50)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
matches!(
|
||||||
|
&e.event,
|
||||||
|
ForensicEvent::Decision { decision, .. } if decision == "charge-conclusion"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(conclusion_edges.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_charge_source_that_stops_going_down_is_visible_as_degraded() {
|
||||||
|
// The reporter dies: the source says nothing for longer than
|
||||||
|
// SOURCE_DOWN_AFTER, and the machine calls it Down like any other —
|
||||||
|
// a battery readout frozen at its last value is an outage, not a
|
||||||
|
// reading.
|
||||||
|
let mut sm = DeviceStateMachine::new();
|
||||||
|
let t0 = Instant::now();
|
||||||
|
sm.mark_evidence_seen_at(SensorSource::Charge, t0);
|
||||||
|
sm.tick_at(t0 + SOURCE_DOWN_AFTER + Duration::from_secs(1));
|
||||||
|
assert_eq!(
|
||||||
|
sm.source_health.get_mut(SensorSource::Charge),
|
||||||
|
&mut SourceHealth::Down
|
||||||
|
);
|
||||||
|
assert!(sm.to_ipc_json()["sensors_degraded"] == true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
//! same pattern as machined and secrets.
|
//! same pattern as machined and secrets.
|
||||||
|
|
||||||
pub mod bearer;
|
pub mod bearer;
|
||||||
pub mod charge;
|
|
||||||
pub mod device_state;
|
pub mod device_state;
|
||||||
pub mod draw;
|
pub mod draw;
|
||||||
pub mod idle;
|
pub mod idle;
|
||||||
|
|
|
||||||
|
|
@ -656,6 +656,10 @@ pub enum SensorSource {
|
||||||
Accelerometer,
|
Accelerometer,
|
||||||
Light,
|
Light,
|
||||||
Touch,
|
Touch,
|
||||||
|
/// The power supplies under `/sys/class/power_supply`. Reported by the
|
||||||
|
/// same reporter as the others — charge is evidence, and it enters the
|
||||||
|
/// machine through the same gate.
|
||||||
|
Charge,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SensorSource {
|
impl SensorSource {
|
||||||
|
|
@ -668,10 +672,33 @@ impl SensorSource {
|
||||||
SensorSource::Accelerometer => "accel",
|
SensorSource::Accelerometer => "accel",
|
||||||
SensorSource::Light => "light",
|
SensorSource::Light => "light",
|
||||||
SensorSource::Touch => "touch",
|
SensorSource::Touch => "touch",
|
||||||
|
SensorSource::Charge => "charge",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the kernel says the battery and its supplies are doing, verbatim.
|
||||||
|
///
|
||||||
|
/// Fields are kept as the kernel's own strings rather than re-enumed: a value
|
||||||
|
/// the crate does not recognise must survive to the readout untouched, because
|
||||||
|
/// "the kernel said something new" is itself the evidence. A non-battery
|
||||||
|
/// supply asserting `online` counts as plugged; `None` means no supply node
|
||||||
|
/// was readable at all — absence is not "unplugged".
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct ChargeFields {
|
||||||
|
pub plugged: Option<bool>,
|
||||||
|
/// The fuel gauge's `status`: "Charging", "Discharging", "Not charging",
|
||||||
|
/// "Full", or whatever the driver said.
|
||||||
|
pub status: Option<String>,
|
||||||
|
/// The charger's `charge_type` — "Fast", "Slow", "Trickle" on blueline's
|
||||||
|
/// pmi8998. The fuel gauge has no such attribute; it is read off the
|
||||||
|
/// charger side.
|
||||||
|
pub charge_type: Option<String>,
|
||||||
|
/// Fuel gauge percentage, 0–100.
|
||||||
|
pub capacity: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum SensorValue {
|
pub enum SensorValue {
|
||||||
|
|
@ -683,6 +710,8 @@ pub enum SensorValue {
|
||||||
Changing(bool),
|
Changing(bool),
|
||||||
/// Touch: active or inactive.
|
/// Touch: active or inactive.
|
||||||
Active(bool),
|
Active(bool),
|
||||||
|
/// The raw charge reading — evidence only, interpreted by the machine.
|
||||||
|
Charge(ChargeFields),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What sessiond currently is, as reported by `status`.
|
/// What sessiond currently is, as reported by `status`.
|
||||||
|
|
|
||||||
|
|
@ -190,10 +190,6 @@ fn spawn_clock(shared: &Arc<Shared>) {
|
||||||
let home = shared.lock().device_state.policy.home_ssids.clone();
|
let home = shared.lock().device_state.policy.home_ssids.clone();
|
||||||
let evidence = crate::sessiond::bearer::probe(&home);
|
let evidence = crate::sessiond::bearer::probe(&home);
|
||||||
shared.lock().device_state.note_bearer(evidence);
|
shared.lock().device_state.note_bearer(evidence);
|
||||||
// Charge rides the same cadence: sysfs reads, no subprocess,
|
|
||||||
// and a 5 s resolution is far faster than charging moves.
|
|
||||||
let charge = crate::sessiond::charge::probe();
|
|
||||||
shared.lock().device_state.note_charge(charge);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hold the lock only to decide, never while running a command.
|
// Hold the lock only to decide, never while running a command.
|
||||||
|
|
@ -1530,6 +1526,20 @@ fn handle_request(
|
||||||
}
|
}
|
||||||
Request::SensorInput(input) => {
|
Request::SensorInput(input) => {
|
||||||
let mut d = shared.lock();
|
let mut d = shared.lock();
|
||||||
|
// Charge enters through the same gate as every other source — the
|
||||||
|
// reporter reads the supplies, the machine interprets. It carries
|
||||||
|
// no presence evidence, so it skips the SensorEvidence lane below.
|
||||||
|
if let (SensorSource::Charge, SensorValue::Charge(fields)) =
|
||||||
|
(&input.source, &input.value)
|
||||||
|
{
|
||||||
|
d.device_state.note_charge(fields.clone());
|
||||||
|
d.device_state.mark_evidence_seen(input.source);
|
||||||
|
info!(
|
||||||
|
"[device-state] sensor charge = {:?}",
|
||||||
|
d.device_state.charge
|
||||||
|
);
|
||||||
|
return serde_json::json!({ "ok": true });
|
||||||
|
}
|
||||||
let evidence = &mut d.device_state.sensor_evidence;
|
let evidence = &mut d.device_state.sensor_evidence;
|
||||||
match (&input.source, &input.value) {
|
match (&input.source, &input.value) {
|
||||||
(SensorSource::Proximity, SensorValue::Near(v)) => {
|
(SensorSource::Proximity, SensorValue::Near(v)) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue