Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-15 11:45:22 -04:00
commit 1336960ca5
9 changed files with 377 additions and 213 deletions

View file

@ -243,12 +243,21 @@ jobs:
file "$B" | grep -q 'x86-64' || { echo "sessiond is not x86_64" >&2; exit 1; }
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 \
souveraine-sessiond-aarch64 souveraine-sensord-aarch64 \
souveraine-usb-kvm-aarch64 \
souveraine-secrets-aarch64 souveraine-secrets-x86_64 \
souveraine-machined-aarch64 souveraine-machined-x86_64 \
souveraine-sessiond-x86_64 \
souveraine-sessiond-x86_64 souveraine-sensord-x86_64 \
> souveraine-binaries.sha256
- name: Build UPower fork (per-arch install trees)
@ -395,14 +404,14 @@ jobs:
cp "souveraine-secrets-$ARCH" "$PKG_WORK/souveraine-secrets-binary"
cp "souveraine-machined-$ARCH" "$PKG_WORK/souveraine-machined-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 \
packaging/org.freedesktop.secrets.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.
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"
# The wry host. Guarded, and the PKGBUILD source line is removed
# 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-sessiond'
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'
if [ "$ARCH" = aarch64 ]; then
bsdtar -tf "$PKG" | grep -qx 'usr/bin/souveraine-usb-kvm'

View file

@ -17,18 +17,18 @@ source=('souveraine-binary' 'souveraine.service'
'souveraine-secrets-binary' 'souveraine-secrets.service'
'souveraine-machined-binary' 'souveraine-machined.service'
'souveraine-sessiond-binary' 'souveraine-sessiond.service'
'souveraine-sensord-binary' 'souveraine-sensord.service'
'souveraine-sessiond.pam'
'souveraine-verify-trail' 'souveraine-button'
'souveraine-stepup.pam' 'org.souveraine.stepup.policy'
'LICENSE')
sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' 'SKIP' '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')
# sessiond is built and packaged for both supported architectures. Installing
# its user unit does not enable or start it; activation remains target-owned.
if [ "$CARCH" = aarch64 ]; then
source+=('souveraine-sensord-binary' 'souveraine-sensord.service'
'souveraine-usb-kvm-binary')
sha256sums+=('SKIP' 'SKIP' 'SKIP')
source+=('souveraine-usb-kvm-binary')
sha256sums+=('SKIP')
depends+=('wayland' 'libx11' 'libxcursor' 'libxi' 'libxkbcommon' 'libxrandr')
# The wry host links the system webview, so it only cross-builds once
# webkit2gtk is in the aarch64 sysroot. Declared only when CI actually
@ -56,13 +56,16 @@ package() {
# had not been touched since (TASK-76).
install -Dm644 "$srcdir/souveraine-sessiond.pam" \
"$pkgdir/etc/pam.d/souveraine-sessiond"
# Sensor reporter, aarch64 only — the laptop has no iio-sensor-proxy
# sources worth reporting and no device state machine listening.
# The wry host, aarch64 only (webkit2gtk in the sysroot is the gate).
if [ -f "$srcdir/souveraine-web-binary" ]; then
install -Dm755 "$srcdir/souveraine-web-binary" \
"$pkgdir/usr/bin/souveraine-web"
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
install -Dm755 "$srcdir/souveraine-sensord-binary" \
"$pkgdir/usr/bin/souveraine-sensord"

View file

@ -1,5 +1,5 @@
[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
# scripts (blueline-proximity-lock and the light/accel copies that were about to
# be written) — see DEVICE-STATE-MACHINE.md §10 and §12.

View file

@ -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
//! 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.
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
/// `SensorInput` expects. Shared with the keepalive thread.
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> {
let mut cmd = Command::new("monitor-sensor");
for s in SOURCES {
@ -357,6 +452,7 @@ fn main() {
// like a fresh change.
let parser: Parser = Arc::new(Mutex::new(ParserState::default()));
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
// process is the recovery path for a sensor stack that came back after an

View file

@ -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, 0100.
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");
}
}

View file

@ -21,10 +21,9 @@ use std::time::{Duration, Instant};
use tracing::{info, warn};
use crate::sessiond::bearer::{Bearer, BearerEvidence};
use crate::sessiond::charge::ChargeEvidence;
use crate::sessiond::protocol::{
Button, ButtonEdge, ButtonGesture, InputTrigger, PowerVerb, SensorSource, SensorValue,
TouchGesture, UsbMode,
Button, ButtonEdge, ButtonGesture, ChargeFields, InputTrigger, PowerVerb, SensorSource,
SensorValue, TouchGesture, UsbMode,
};
/// 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*.
///
/// Exactly what `souveraine-sensord` reports (`SOURCES` in its `main`):
/// proximity and light. `Touch` has no reporter either, so it stays `Unknown`
/// and silent per §10. A source listed here with nothing behind it
/// manufactures a permanent false alarm — the failure this mechanism exists to
/// avoid in the other direction — and it did: accelerometer stayed here after
/// `6b67512` (2026-08-04) dropped the claim, so every boot went `Absent` at
/// 300 s and `sensors_degraded` read true on 3074 consecutive snapshots.
/// Exactly what `souveraine-sensord` reports: proximity and light off
/// iio-sensor-proxy, charge off `/sys/class/power_supply`. `Touch` has no
/// reporter either, so it stays `Unknown` and silent per §10. A source
/// listed here with nothing behind it manufactures a permanent false alarm
/// — the failure this mechanism exists to avoid in the other direction —
/// and it did: accelerometer stayed here after `6b67512` (2026-08-04)
/// 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
/// 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.
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.
///
@ -494,6 +498,7 @@ pub struct EvidenceSeen {
pub accel: Option<Instant>,
pub light: Option<Instant>,
pub touch: Option<Instant>,
pub charge: Option<Instant>,
}
impl EvidenceSeen {
@ -506,6 +511,7 @@ impl EvidenceSeen {
SensorSource::Accelerometer => self.accel,
SensorSource::Light => self.light,
SensorSource::Touch => self.touch,
SensorSource::Charge => self.charge,
}
}
}
@ -576,6 +582,7 @@ pub struct SourceHealthTable {
pub accel: SourceHealth,
pub light: SourceHealth,
pub touch: SourceHealth,
pub charge: SourceHealth,
}
impl SourceHealthTable {
@ -585,6 +592,7 @@ impl SourceHealthTable {
SensorSource::Accelerometer => &mut self.accel,
SensorSource::Light => &mut self.light,
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
/// evidence that stopped.
pub fn any_down(&self) -> bool {
[self.proximity, self.accel, self.light, self.touch]
.iter()
.any(|h| matches!(h, SourceHealth::Down | SourceHealth::Absent))
[
self.proximity,
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 {
@ -606,6 +620,7 @@ impl SourceHealthTable {
"accel": self.accel.as_str(),
"light": self.light.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
/// the DPMS executor.
pub bearer: BearerEvidence,
/// Charge evidence, refreshed by the daemon's probe — the last raw feed
/// taken off the glass (TASK-33). Never inferred here, same contract as
/// `bearer`.
pub charge: ChargeEvidence,
/// Charge evidence, as reported by sensord through `sensor_input`. The
/// machine never reads the supplies itself: a decider that also probes is
/// one refactor away from being a driver (DEVICE-STATE-MACHINE.md §10).
pub charge: ChargeFields,
/// 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>,
/// The bearer the machine has actually acted on, and when the current
/// candidate first differed from it.
@ -1650,7 +1665,7 @@ impl DeviceStateMachine {
// and a setting that reverts on reboot is not a setting.
policy: DeviceStatePolicy::load(),
bearer: BearerEvidence::default(),
charge: ChargeEvidence {
charge: ChargeFields {
plugged: None,
status: None,
charge_type: None,
@ -2183,6 +2198,7 @@ impl DeviceStateMachine {
(SensorSource::Accelerometer, self.evidence_seen.accel),
(SensorSource::Light, self.evidence_seen.light),
(SensorSource::Touch, self.evidence_seen.touch),
(SensorSource::Charge, self.evidence_seen.charge),
];
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
/// change once, not once per probe — same rule as the bearer's.
pub fn note_charge(&mut self, evidence: ChargeEvidence) {
/// change once, not once per report — same rule as the bearer's.
///
/// 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;
let conclusion = self.charge.conclusion();
let conclusion = Self::conclude_charge(&self.charge);
if self.charge_conclusion != Some(conclusion) {
let previous = self.charge_conclusion;
self.charge_conclusion = Some(conclusion);
let mut inputs = self.charge.as_json();
let mut inputs = self.charge_json();
inputs["previous_conclusion"] =
previous.map_or(serde_json::Value::Null, |p| p.into());
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.
///
/// 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::Light => self.evidence_seen.light = 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);
@ -3217,6 +3273,8 @@ impl DeviceStateMachine {
SensorSource::Touch => {
SensorValue::Active(self.sensor_evidence.touch_active)
}
// Charge is ingested by the gate handler, never here.
SensorSource::Charge => unreachable!(),
},
confidence,
},
@ -3348,6 +3406,7 @@ impl DeviceStateMachine {
"accel": self.is_fresh(self.evidence_seen.accel),
"light": self.is_fresh(self.evidence_seen.light),
"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.
// `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),
"light": Self::secs_since(self.evidence_seen.light),
"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
// be believed, `health` says whether the source is there at all.
"sensor_health": self.source_health.as_json(),
"sensors_degraded": self.source_health.any_down(),
// Charging as the machine's own evidence — the last raw feed off
// the glass (TASK-33). Surfaces render `conclusion`; they do not
// re-derive it.
"charge": self.charge.as_json(),
// Charging as the machine's own evidence — reported by sensord,
// interpreted here (TASK-33). Surfaces render `conclusion`; they
// do not re-derive it.
"charge": self.charge_json(),
})
}
@ -4870,6 +4930,7 @@ mod tests {
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
assert_eq!(sm.source_health.light, SourceHealth::Absent);
assert_eq!(sm.source_health.charge, SourceHealth::Absent);
assert!(
sm.source_health.any_down(),
"absent evidence is degraded evidence"
@ -4890,7 +4951,7 @@ mod tests {
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
.collect();
assert_eq!(errors.len(), 2, "one entry per expected source, once");
assert_eq!(errors.len(), 3, "one entry per expected source, once");
}
#[test]
@ -4906,7 +4967,7 @@ mod tests {
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
.count();
assert_eq!(errors, 2, "two expected sources, one entry each");
assert_eq!(errors, 3, "three expected sources, one entry each");
}
#[test]
@ -5499,4 +5560,125 @@ mod tests {
"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);
}
}

View file

@ -11,7 +11,6 @@
//! same pattern as machined and secrets.
pub mod bearer;
pub mod charge;
pub mod device_state;
pub mod draw;
pub mod idle;

View file

@ -656,6 +656,10 @@ pub enum SensorSource {
Accelerometer,
Light,
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 {
@ -668,10 +672,33 @@ impl SensorSource {
SensorSource::Accelerometer => "accel",
SensorSource::Light => "light",
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, 0100.
pub capacity: Option<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorValue {
@ -683,6 +710,8 @@ pub enum SensorValue {
Changing(bool),
/// Touch: active or inactive.
Active(bool),
/// The raw charge reading — evidence only, interpreted by the machine.
Charge(ChargeFields),
}
/// What sessiond currently is, as reported by `status`.

View file

@ -190,10 +190,6 @@ fn spawn_clock(shared: &Arc<Shared>) {
let home = shared.lock().device_state.policy.home_ssids.clone();
let evidence = crate::sessiond::bearer::probe(&home);
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.
@ -1530,6 +1526,20 @@ fn handle_request(
}
Request::SensorInput(input) => {
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;
match (&input.source, &input.value) {
(SensorSource::Proximity, SensorValue::Near(v)) => {