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; }
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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,7 +603,13 @@ 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]
|
||||
[
|
||||
self.proximity,
|
||||
self.accel,
|
||||
self.light,
|
||||
self.touch,
|
||||
self.charge,
|
||||
]
|
||||
.iter()
|
||||
.any(|h| matches!(h, SourceHealth::Down | SourceHealth::Absent))
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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, 0–100.
|
||||
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`.
|
||||
|
|
|
|||
|
|
@ -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)) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue