Watch
1
0
Fork
You've already forked souveraine
0

sessiond: carry charging as machine evidence

TASK-33 part 2: the machine owns the conclusion — plugged, status,
charge type, and what it means — probed on the bearer's cadence,
one decision made once on the trail. Surfaces render it; they do
not re-derive it.
This commit is contained in:
Fimeg 2026-08-15 06:10:08 -04:00
commit 24086b3540
4 changed files with 208 additions and 0 deletions

165
src/sessiond/charge.rs Normal file
View file

@ -0,0 +1,165 @@
//! 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,6 +21,7 @@ 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,
@ -1545,6 +1546,13 @@ 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,
/// The conclusion last recorded, so the trail carries the edge rather
/// than one entry per probe.
charge_conclusion: Option<&'static str>,
/// The bearer the machine has actually acted on, and when the current
/// candidate first differed from it.
///
@ -1642,6 +1650,13 @@ impl DeviceStateMachine {
// and a setting that reverts on reboot is not a setting.
policy: DeviceStatePolicy::load(),
bearer: BearerEvidence::default(),
charge: ChargeEvidence {
plugged: None,
status: None,
charge_type: None,
capacity: None,
},
charge_conclusion: None,
bearer_applied: None,
bearer_candidate_since: None,
bearer_deaf: false,
@ -2256,6 +2271,25 @@ impl DeviceStateMachine {
self.bearer = evidence;
}
/// 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) {
self.charge = evidence;
let conclusion = self.charge.conclusion();
if self.charge_conclusion != Some(conclusion) {
let previous = self.charge_conclusion;
self.charge_conclusion = Some(conclusion);
let mut inputs = self.charge.as_json();
inputs["previous_conclusion"] =
previous.map_or(serde_json::Value::Null, |p| p.into());
self.record_decision(
"charge-conclusion",
inputs,
"what the machine concludes about charging changed — one decision, made once (TASK-33)",
);
}
}
/// Decide the bearer posture, subject to the settling window.
///
/// Returns at most one `PreferLink` and one `PinTunnelUnderlay`, and only
@ -3328,6 +3362,10 @@ impl DeviceStateMachine {
// 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(),
})
}

View file

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

View file

@ -190,6 +190,10 @@ 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.