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:
parent
0dd8aaaba0
commit
24086b3540
4 changed files with 208 additions and 0 deletions
165
src/sessiond/charge.rs
Normal file
165
src/sessiond/charge.rs
Normal 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, 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,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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue