Watch
1
0
Fork
You've already forked souveraine
0

sessiond: the power button reports edges; the machine recognises gestures

blueline-power-button decided policy in shell script — read its own panel
state, asked the shell to lock over qs ipc, polled, then blanked the panel
itself. That was a path to a dark panel outside request_blank(), so §1's
lock-then-blank invariant had a hole in the most-used control on the device.

Edges in, gestures out: tap, double, triple, hold, long-hold, per button.
Only the power tap is bound; the rest are recognised, recorded and inert
until there is a binding table to point them at.
This commit is contained in:
Fimeg 2026-07-31 21:45:47 -04:00
commit 86afe00dba
3 changed files with 322 additions and 2 deletions

View file

@ -20,7 +20,9 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use crate::sessiond::protocol::{InputTrigger, SensorSource, SensorValue};
use crate::sessiond::protocol::{
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue,
};
/// How long a locked, lit panel waits for input before it blanks.
///
@ -123,6 +125,31 @@ pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700);
/// wake through, which is the recoverable direction.
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
/// How long a button must stay down to be a hold rather than a tap.
///
/// **Provisional, and the number is the weakest part of this file.** §9.5's
/// lesson is the standing warning here: the proximity debounce was specified
/// from Android's prior art, and when it came time to build it the trail said
/// the specification was wrong for a device whose constraints had changed. 500
/// ms is AOSP's long-press default and it is a *reference*, not a measurement.
/// The trail now records every gesture with the duration that produced it, so
/// this can be set from real presses the way `PROXIMITY_NEAR_DEBOUNCE` was —
/// do that before defending the value.
pub const BUTTON_HOLD: Duration = Duration::from_millis(500);
/// Held past this, still down. The point where a destructive binding may fire
/// without the user having meant a hold.
pub const BUTTON_LONG_HOLD: Duration = Duration::from_millis(2000);
/// How long after a release to keep waiting for another tap.
///
/// This one is felt directly and in the wrong direction: it is the delay
/// between a single tap and anything happening, because a single cannot fire
/// until the window proves no second tap is coming. Too long and the phone
/// feels broken; too short and a double-tap fires a single first. 300 ms is
/// AOSP's double-tap timeout. Same caveat as above — measure it.
pub const BUTTON_MULTI_TAP_WINDOW: Duration = Duration::from_millis(300);
/// How long a blank waits for the compositor to acknowledge the lock it asked
/// for before going dark anyway.
///
@ -729,6 +756,96 @@ impl ForensicLog {
}
}
/// One button's edges, accumulating into gestures.
///
/// This is the chordotonal principle applied to a button: the thing that turns
/// a run of drivers (down, up, down, up) into one piece of evidence ("double
/// tap") lives below the decision, and only the compressed answer travels
/// upward. A consumer never sees the edges.
///
/// Deliberately per-button and stateless about *meaning*. It recognises; it
/// does not decide what a triple-tap is for. Binding is policy and lives above.
#[derive(Debug, Clone, Default)]
pub struct ButtonRecognizer {
/// When the button went down, while it is down.
down_since: Option<Instant>,
/// Holds already announced for the current press, so `tick` does not
/// re-fire them every second while a finger rests on the button.
hold_fired: bool,
long_hold_fired: bool,
/// Releases that have not yet resolved into a tap gesture, and when the
/// most recent one landed.
pending_taps: u8,
last_release: Option<Instant>,
}
impl ButtonRecognizer {
/// A press edge. Returns nothing — a press alone is never a gesture, and
/// pretending otherwise is what makes a double-tap fire a single first.
pub fn down(&mut self, now: Instant) {
self.down_since = Some(now);
self.hold_fired = false;
self.long_hold_fired = false;
}
/// A release edge. Returns a gesture only when the release *completes* one
/// immediately — which is never, for taps. A hold that already fired
/// resolves to nothing here: it was announced while the button was down,
/// and announcing it again on release would double-fire every binding.
pub fn up(&mut self, now: Instant) -> Option<ButtonGesture> {
let held = self.down_since.take().map(|t| now.duration_since(t));
if self.hold_fired {
// A hold was already announced. Releasing ends it and starts no
// tap — a long press is not also a tap, and counting it as one is
// how "hold to power off" also toggles your screen.
self.pending_taps = 0;
self.last_release = None;
return None;
}
if held.is_some_and(|d| d >= BUTTON_HOLD) {
// Held long enough, but tick never saw it (a press shorter than one
// tick interval that still crossed the threshold). Announce now.
self.pending_taps = 0;
self.last_release = None;
return Some(ButtonGesture::Hold);
}
self.pending_taps = self.pending_taps.saturating_add(1);
self.last_release = Some(now);
None
}
/// Called on every tick. Fires holds while the button is still down, and
/// resolves pending taps once the multi-tap window has closed.
pub fn tick(&mut self, now: Instant) -> Option<ButtonGesture> {
if let Some(since) = self.down_since {
let held = now.duration_since(since);
if held >= BUTTON_LONG_HOLD && !self.long_hold_fired {
self.long_hold_fired = true;
return Some(ButtonGesture::LongHold);
}
if held >= BUTTON_HOLD && !self.hold_fired {
self.hold_fired = true;
return Some(ButtonGesture::Hold);
}
return None;
}
let last = self.last_release?;
if now.duration_since(last) < BUTTON_MULTI_TAP_WINDOW {
return None;
}
let n = std::mem::take(&mut self.pending_taps);
self.last_release = None;
match n {
0 => None,
1 => Some(ButtonGesture::Tap),
2 => Some(ButtonGesture::DoubleTap),
// Four taps is a triple plus a stray, not a new gesture. Saturating
// here beats inventing a QuadrupleTap nobody asked for.
_ => Some(ButtonGesture::TripleTap),
}
}
}
/// Is this entry worth waking a mind for?
///
/// The whole value of the subscribe stream is this predicate. §10 is the
@ -1130,6 +1247,9 @@ pub struct DeviceStateMachine {
/// expected source which has not reported *yet* from one that is never
/// going to — see `SOURCE_EXPECTED_WITHIN`.
started_at: Instant,
/// Per-button gesture recognition. Keyed rather than a field per button so
/// adding volume costs nothing and binds nothing.
buttons: std::collections::HashMap<Button, ButtonRecognizer>,
/// True once a blank has been asked for, so we ask exactly once per wake
/// instead of every tick.
blank_requested: bool,
@ -1225,6 +1345,7 @@ impl DeviceStateMachine {
evidence_seen: EvidenceSeen::default(),
source_health: SourceHealthTable::default(),
started_at: Instant::now(),
buttons: std::collections::HashMap::new(),
blank_requested: false,
dimmed: false,
pending_blank: None,
@ -1267,7 +1388,113 @@ impl DeviceStateMachine {
/// `tick` with the clock passed in, so the rules can be tested at any
/// point on the timeline instead of by sleeping.
/// A button edge. Returns any actions the resulting gesture asks for.
///
/// A press is user input regardless of what it turns out to mean, so the
/// idle budget resets on the DOWN edge, not on the gesture. Waiting for
/// recognition would let the multi-tap window count against a user who is
/// visibly touching the device.
pub fn button_edge(&mut self, button: Button, edge: ButtonEdge) -> Vec<Action> {
self.button_edge_at(button, edge, Instant::now())
}
pub fn button_edge_at(
&mut self,
button: Button,
edge: ButtonEdge,
now: Instant,
) -> Vec<Action> {
let rec = self.buttons.entry(button).or_default();
let gesture = match edge {
ButtonEdge::Down => {
rec.down(now);
None
}
ButtonEdge::Up => rec.up(now),
};
let mut actions = Vec::new();
if edge == ButtonEdge::Down {
// A hardware button is intent — §4's table says of the power button
// "can it lie? no — hardware signal" — so it is never subject to the
// proximity veto that governs tap-to-wake and squeeze. Input is
// noted on the DOWN edge because a finger on the button is a user
// present, whatever the press turns out to mean; making the idle
// budget wait for recognition would count the multi-tap window
// against someone visibly touching the device.
actions.extend(self.note_input(InputTrigger::PowerButton));
}
if let Some(g) = gesture {
actions.extend(self.apply_gesture(button, g, now));
}
actions
}
/// Bind a recognised gesture to behaviour.
///
/// **This match is a placeholder for a binding table.** The end state is
/// Activator-shaped: `(button, gesture) -> action` as editable, persisted
/// data, so a triple-tap can be pointed at something without a rebuild.
/// `DeviceStatePolicy` is already the persisted home for exactly this kind
/// of setting ("a setting that reverts on reboot is not a setting") and is
/// where the table belongs. Recognition is deliberately separate from
/// binding so that change touches only this function.
///
/// Only the power tap is bound today, and it is bound to what the button
/// already did — wake if the panel is dark, otherwise lock-then-blank
/// through `request_blank()` like every other path. Everything else is
/// recognised, recorded, and inert on purpose: a gesture that fires
/// something nobody chose is worse than one that fires nothing.
fn apply_gesture(
&mut self,
button: Button,
gesture: ButtonGesture,
now: Instant,
) -> Vec<Action> {
self.record_decision(
"button-gesture",
serde_json::json!({
"button": button.as_str(),
"gesture": gesture.as_str(),
"bound": button == Button::Power && gesture == ButtonGesture::Tap,
}),
"hardware button recognised",
);
if button != Button::Power || gesture != ButtonGesture::Tap {
return Vec::new();
}
if !self.panel_on {
// Dark: the tap is a wake, and a power-button wake is never vetoed.
// `Restore` puts back the brightness the dim captured rather than a
// guessed floor — see the comment on that capture.
self.blank_requested = false;
return vec![Action::Restore];
}
self.request_blank(
now,
serde_json::json!({ "button": "power", "gesture": "tap" }),
"power button tap",
)
}
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
// Buttons first: a hold that crossed its threshold during this tick
// should be announced before the idle rules below decide anything, or
// a hold and a blank can land in the same tick in the wrong order.
let mut button_actions = Vec::new();
let pending: Vec<(Button, ButtonGesture)> = self
.buttons
.iter_mut()
.filter_map(|(b, r)| r.tick(now).map(|g| (*b, g)))
.collect();
for (button, gesture) in pending {
button_actions.extend(self.apply_gesture(button, gesture, now));
}
let mut actions = self.tick_rules_at(now);
button_actions.append(&mut actions);
return button_actions;
}
fn tick_rules_at(&mut self, now: Instant) -> Vec<Action> {
// Expiry first. A source that has gone stale must not be allowed to
// win a pending debounce and then be cleared in the same tick — that
// would put a transition in the trail for a reading nobody confirmed.

View file

@ -174,6 +174,13 @@ pub const VERBS: &[VerbDoc] = &[
refuses: &[RefusalCode::RefusedByState],
example: r#"{"op":"input","trigger":"touch"}"#,
},
VerbDoc {
op: "button",
mutates: true,
summary: "a hardware button edge; the machine recognises taps and holds from these",
refuses: &[RefusalCode::InvalidArgument],
example: r#"{"op":"button","button":"power","edge":"down"}"#,
},
VerbDoc {
op: "panel",
mutates: true,
@ -258,6 +265,23 @@ pub enum Request {
/// This is the wire that lets the machine tell "the user is looking at
/// the lock screen" from "the lock screen has been lit for ten minutes."
Input { trigger: Option<InputTrigger> },
/// A hardware button went down or came up. Edges only — the caller reports
/// what the hardware did and nothing else.
///
/// `blueline-power-button` used to *be* the policy: it read a state file,
/// asked the shell to lock over `qs ipc`, polled `session state` twenty
/// times at 100 ms grepping for `"locked": true`, then called
/// `blueline-screen-toggle off` itself. That is a path to a dark panel that
/// never routes through `request_blank()` — so LOCK-DPMS-LESSONS §1's
/// "every path routes through it, an invariant not a coincidence" had a hole
/// in it, and the hole was the most-used control on the device. It also made
/// the button the eighth blind actor of DEVICE-STATE-MACHINE §1, with its
/// own copy of the lock-then-blank ordering and its own panel truth.
///
/// Now it reports and stops deciding. Recognition — tap, double, triple,
/// hold — happens in the machine, because a gesture is an accumulation over
/// time and time is the one thing a fire-and-forget script does not have.
Button { button: Button, edge: ButtonEdge },
/// The DPMS executor reports the panel's real power state. The machine
/// keeps the panel as a field, not a state: "locked with the screen off"
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
@ -326,6 +350,65 @@ pub enum InputTrigger {
Unknown,
}
/// Which hardware button. Volume is here because the recognizer is per-button
/// and costs nothing to reuse; nothing binds them yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Button {
Power,
VolumeUp,
VolumeDown,
}
impl Button {
pub fn as_str(self) -> &'static str {
match self {
Button::Power => "power",
Button::VolumeUp => "volume_up",
Button::VolumeDown => "volume_down",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ButtonEdge {
Down,
Up,
}
/// What the machine made of a run of edges.
///
/// `Hold` fires while the button is still down — a hold you only learn about on
/// release is a hold that cannot light anything up while you are waiting, and
/// waiting with no feedback is how a user decides the device is broken and lets
/// go. Taps resolve on the multi-tap window expiring, which is the opposite
/// trade and the right one: a double-tap must not first fire a single.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ButtonGesture {
Tap,
DoubleTap,
TripleTap,
/// Held past the first threshold, still down.
Hold,
/// Held past the second, still down. The "you may let go now" point for
/// anything destructive — nothing binds it yet.
LongHold,
}
impl ButtonGesture {
pub fn as_str(self) -> &'static str {
match self {
ButtonGesture::Tap => "tap",
ButtonGesture::DoubleTap => "double_tap",
ButtonGesture::TripleTap => "triple_tap",
ButtonGesture::Hold => "hold",
ButtonGesture::LongHold => "long_hold",
}
}
}
/// A sensor reading fed to the device state machine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorInput {
@ -421,7 +504,7 @@ mod tests {
// And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc.
assert_eq!(VERBS.len(), 13, "a Request variant was added without a VerbDoc");
assert_eq!(VERBS.len(), 14, "a Request variant was added without a VerbDoc");
}
#[test]

View file

@ -980,6 +980,16 @@ fn handle_request(
}
serde_json::json!({ "ok": true, "dim_cancelled": cancelled })
}
Request::Button { button, edge } => {
let actions = {
let mut d = shared.lock();
d.device_state.button_edge(button, edge)
};
for action in actions {
execute(shared, action);
}
serde_json::json!({ "ok": true })
}
Request::Panel { on } => {
let actions = {
let mut d = shared.lock();