umsh_ux_display_tracker/
attention.rs

1//! Display-attention policy: when the device should stop assuming the
2//! user is looking at it.
3//!
4//! Both panel technologies lapse after a period of inactivity; what
5//! lapsing *does* differs, because their costs differ:
6//!
7//! - [`DisplayKind::Emissive`] (OLED) burns current for as long as it is
8//!   lit, so lapsing turns the panel off — via a dimmed warning state
9//!   first, so it reads as going to sleep rather than dying.
10//! - [`DisplayKind::Persistent`] (e-paper) costs nothing to keep
11//!   readable, so it stays visible. Lapsing instead collapses the menu
12//!   back to its home page.
13//!
14//! The shared part is the one that matters to the user: after a while
15//! away, the device forgets what you were in the middle of, and the next
16//! press starts from a page whose meaning is visible. Both kinds
17//! therefore send the menu home on [`Transition::Lapsed`] (see
18//! [`crate::menu::UiModel::go_home`]); only emissive panels also cut
19//! power.
20//!
21//! # Driving it
22//!
23//! 1. Call [`Attention::wake`] on every event that means "the user is
24//!    here or wants to be": a button press (on the press edge, not the
25//!    release), a BLE connection-state change, an opening pairing
26//!    window, an alert, a low-battery notice.
27//! 2. Call [`Attention::set_hold`] for conditions that must stay visible
28//!    for as long as they last, such as a pairing window showing a PIN.
29//! 3. Call [`Attention::poll`] when [`Attention::next_deadline`]
30//!    elapses, and act on any [`Transition`] it returns.
31//!
32//! Content changes that are *not* the user's doing — a battery sample, a
33//! bond count — must not call `wake`. Redraw them only while
34//! [`Attention::accepts_redraw`] is true, or a board that samples its
35//! battery on a timer will never let its panel sleep.
36
37use core::time::Duration;
38
39/// How the board's panel behaves when it is not being looked at.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum DisplayKind {
42    /// Lit panel (OLED). Costs power while visible; can be dimmed and
43    /// switched off.
44    Emissive,
45    /// Bistable panel (e-paper). Readable at zero power; never switched
46    /// off while the device is awake.
47    Persistent,
48}
49
50/// Timing policy. Held by value so a board can adjust it at runtime —
51/// the plumbing a future `PROP_DISPLAY_TIMEOUT` needs.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct AttentionConfig {
54    /// Inactivity before attention lapses.
55    pub timeout: Duration,
56    /// How long before `timeout` an emissive panel dims as a warning.
57    /// Zero (or anything at least as large as `timeout`) disables the
58    /// dim state. Ignored for persistent panels.
59    pub dim_margin: Duration,
60}
61
62impl AttentionConfig {
63    /// OLED default: off after 10 s, dimmed for the last 3 s of that.
64    pub const EMISSIVE: Self = Self {
65        timeout: Duration::from_secs(10),
66        dim_margin: Duration::from_secs(3),
67    };
68
69    /// E-paper default: menu returns home after 30 s. Longer than the
70    /// emissive timeout because nothing is being spent to keep the
71    /// screen readable — only stale menu context is at stake — and
72    /// because each partial refresh is visible enough that a twitchy
73    /// fallback would be an annoyance of its own.
74    pub const PERSISTENT: Self = Self {
75        timeout: Duration::from_secs(30),
76        dim_margin: Duration::ZERO,
77    };
78
79    /// The instant, relative to the last activity, at which the panel
80    /// should dim. `None` when this config has no dim state.
81    fn dim_after(&self, kind: DisplayKind) -> Option<Duration> {
82        if kind != DisplayKind::Emissive
83            || self.dim_margin.is_zero()
84            || self.dim_margin >= self.timeout
85        {
86            return None;
87        }
88        Some(self.timeout - self.dim_margin)
89    }
90}
91
92/// A condition that pins the display awake for as long as it holds.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum HoldReason {
95    /// A pairing window is open. Its PIN has to stay readable for the
96    /// whole window.
97    Pairing,
98    /// A locate alert is running — the display is part of being found.
99    Alert,
100    /// A guided maintenance or update flow is on screen.
101    Maintenance,
102    /// A shutdown or power-off confirmation is counting down.
103    Shutdown,
104}
105
106impl HoldReason {
107    const fn bit(self) -> u8 {
108        1 << self as u8
109    }
110}
111
112/// What the panel is currently doing.
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
114pub enum DisplayState {
115    /// Being looked at: lit at full brightness, menu navigable.
116    Active,
117    /// Emissive only: still lit, dimmed, about to lapse.
118    Dim,
119    /// Attention has lapsed. Emissive panels are off; persistent panels
120    /// are showing their home page.
121    Lapsed,
122}
123
124/// A state change the owning task has to act on.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub enum Transition {
127    /// Attention regained. Emissive: draw the fresh frame *first*, then
128    /// power the panel on (and restore full contrast if it was dimmed),
129    /// so the user never catches a stale frame. Persistent: just redraw.
130    Woke,
131    /// Emissive only: drop to the dimmed contrast.
132    Dimmed,
133    /// Attention lapsed. Send the menu home, then — emissive only —
134    /// power the panel off.
135    Lapsed,
136}
137
138/// Display-attention state machine.
139///
140/// Pure logic driven by a monotonic millisecond clock: no timers, no
141/// hardware. Every method that can change state takes `now_ms` so the
142/// whole thing is testable with synthetic time.
143#[derive(Clone, Copy, Debug)]
144pub struct Attention {
145    kind: DisplayKind,
146    config: AttentionConfig,
147    state: DisplayState,
148    holds: u8,
149    /// Timestamp the current inactivity window is measured from.
150    since_ms: u64,
151}
152
153impl Attention {
154    /// Start in [`DisplayState::Active`] — boot is itself a wake event.
155    pub fn new(kind: DisplayKind, config: AttentionConfig, now_ms: u64) -> Self {
156        Self {
157            kind,
158            config,
159            state: DisplayState::Active,
160            holds: 0,
161            since_ms: now_ms,
162        }
163    }
164
165    pub fn kind(&self) -> DisplayKind {
166        self.kind
167    }
168
169    pub fn state(&self) -> DisplayState {
170        self.state
171    }
172
173    pub fn config(&self) -> AttentionConfig {
174        self.config
175    }
176
177    /// Replace the timing policy. The new timeout is measured from the
178    /// existing activity mark, so shortening it below the time already
179    /// elapsed lapses at the next [`poll`](Self::poll) rather than
180    /// retroactively.
181    pub fn set_config(&mut self, config: AttentionConfig) {
182        self.config = config;
183    }
184
185    /// True once attention has lapsed.
186    pub fn is_lapsed(&self) -> bool {
187        matches!(self.state, DisplayState::Lapsed)
188    }
189
190    /// Whether the panel can show a redraw right now.
191    ///
192    /// False only for an emissive panel that has been powered off:
193    /// pushing pixels at a dark panel wastes bus traffic and, on a
194    /// board that samples its battery on a timer, would otherwise run
195    /// forever. Persistent panels always accept a redraw — that is how
196    /// their lapse is rendered.
197    pub fn accepts_redraw(&self) -> bool {
198        self.kind == DisplayKind::Persistent || !self.is_lapsed()
199    }
200
201    /// Whether any hold is currently pinning the display awake.
202    pub fn held(&self) -> bool {
203        self.holds != 0
204    }
205
206    /// Register user-driven activity.
207    ///
208    /// Returns [`Transition::Woke`] when this actually brought the panel
209    /// back, so the caller can order its redraw and power-on correctly;
210    /// returns `None` when the display was already active and only the
211    /// inactivity timer moved.
212    pub fn wake(&mut self, now_ms: u64) -> Option<Transition> {
213        self.since_ms = now_ms;
214        if matches!(self.state, DisplayState::Active) {
215            return None;
216        }
217        self.state = DisplayState::Active;
218        Some(Transition::Woke)
219    }
220
221    /// Assert or release a hold.
222    ///
223    /// Asserting one also counts as activity, so an event like a pairing
224    /// window opening both wakes the panel and pins it. Releasing the
225    /// last hold restarts the inactivity window from that moment, so the
226    /// user gets a full timeout to read whatever the hold was showing.
227    pub fn set_hold(
228        &mut self,
229        reason: HoldReason,
230        active: bool,
231        now_ms: u64,
232    ) -> Option<Transition> {
233        let before = self.holds;
234        if active {
235            self.holds |= reason.bit();
236        } else {
237            self.holds &= !reason.bit();
238        }
239        if self.holds == before {
240            return None;
241        }
242        if active {
243            return self.wake(now_ms);
244        }
245        if self.holds == 0 {
246            self.since_ms = now_ms;
247        }
248        None
249    }
250
251    /// Advance time. Returns a transition when one becomes due.
252    pub fn poll(&mut self, now_ms: u64) -> Option<Transition> {
253        if self.held() || self.is_lapsed() {
254            return None;
255        }
256        let idle = Duration::from_millis(now_ms.saturating_sub(self.since_ms));
257        if idle >= self.config.timeout {
258            self.state = DisplayState::Lapsed;
259            return Some(Transition::Lapsed);
260        }
261        if matches!(self.state, DisplayState::Active)
262            && let Some(dim_after) = self.config.dim_after(self.kind)
263            && idle >= dim_after
264        {
265            self.state = DisplayState::Dim;
266            return Some(Transition::Dimmed);
267        }
268        None
269    }
270
271    /// Absolute monotonic-millisecond deadline for the next
272    /// [`poll`](Self::poll), if any is pending.
273    pub fn next_deadline(&self) -> Option<u64> {
274        if self.held() || self.is_lapsed() {
275            return None;
276        }
277        let after = match self.state {
278            DisplayState::Active => self
279                .config
280                .dim_after(self.kind)
281                .unwrap_or(self.config.timeout),
282            DisplayState::Dim => self.config.timeout,
283            DisplayState::Lapsed => return None,
284        };
285        Some(self.since_ms.saturating_add(after.as_millis() as u64))
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn oled() -> Attention {
294        Attention::new(DisplayKind::Emissive, AttentionConfig::EMISSIVE, 0)
295    }
296
297    fn epaper() -> Attention {
298        Attention::new(DisplayKind::Persistent, AttentionConfig::PERSISTENT, 0)
299    }
300
301    #[test]
302    fn emissive_dims_then_lapses() {
303        let mut a = oled();
304        assert_eq!(a.next_deadline(), Some(7_000));
305        assert_eq!(a.poll(6_999), None);
306        assert_eq!(a.poll(7_000), Some(Transition::Dimmed));
307        assert_eq!(a.state(), DisplayState::Dim);
308        assert_eq!(a.next_deadline(), Some(10_000));
309        assert_eq!(a.poll(9_999), None);
310        assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
311        assert_eq!(a.state(), DisplayState::Lapsed);
312        assert_eq!(a.next_deadline(), None);
313    }
314
315    #[test]
316    fn lapse_fires_once() {
317        let mut a = oled();
318        a.poll(7_000);
319        assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
320        assert_eq!(a.poll(20_000), None);
321    }
322
323    #[test]
324    fn persistent_lapses_without_dimming() {
325        let mut a = epaper();
326        assert_eq!(a.next_deadline(), Some(30_000));
327        assert_eq!(a.poll(29_999), None);
328        assert_eq!(a.poll(30_000), Some(Transition::Lapsed));
329        assert_eq!(a.state(), DisplayState::Lapsed);
330    }
331
332    #[test]
333    fn persistent_always_accepts_redraw() {
334        let mut a = epaper();
335        assert!(a.accepts_redraw());
336        a.poll(30_000);
337        assert!(a.is_lapsed());
338        assert!(a.accepts_redraw());
339    }
340
341    #[test]
342    fn dark_emissive_panel_refuses_redraw() {
343        let mut a = oled();
344        assert!(a.accepts_redraw());
345        a.poll(7_000);
346        // Dimmed is still visible.
347        assert!(a.accepts_redraw());
348        a.poll(10_000);
349        assert!(!a.accepts_redraw());
350    }
351
352    #[test]
353    fn wake_from_lapsed_reports_the_transition() {
354        let mut a = oled();
355        a.poll(10_000);
356        assert_eq!(a.wake(12_000), Some(Transition::Woke));
357        assert_eq!(a.state(), DisplayState::Active);
358        // Full timeout again, measured from the wake.
359        assert_eq!(a.next_deadline(), Some(19_000));
360    }
361
362    #[test]
363    fn wake_from_dim_restores_full_brightness() {
364        let mut a = oled();
365        a.poll(7_000);
366        assert_eq!(a.state(), DisplayState::Dim);
367        assert_eq!(a.wake(8_000), Some(Transition::Woke));
368        assert_eq!(a.state(), DisplayState::Active);
369    }
370
371    #[test]
372    fn wake_while_active_only_defers_the_deadline() {
373        let mut a = oled();
374        assert_eq!(a.wake(5_000), None);
375        assert_eq!(a.next_deadline(), Some(12_000));
376        assert_eq!(a.poll(10_000), None);
377    }
378
379    #[test]
380    fn a_hold_pins_the_display_awake() {
381        let mut a = oled();
382        a.set_hold(HoldReason::Pairing, true, 1_000);
383        assert_eq!(a.next_deadline(), None);
384        assert_eq!(a.poll(60_000), None);
385        assert_eq!(a.state(), DisplayState::Active);
386    }
387
388    #[test]
389    fn asserting_a_hold_wakes_a_lapsed_panel() {
390        let mut a = oled();
391        a.poll(10_000);
392        assert!(a.is_lapsed());
393        assert_eq!(
394            a.set_hold(HoldReason::Alert, true, 11_000),
395            Some(Transition::Woke)
396        );
397        assert_eq!(a.state(), DisplayState::Active);
398    }
399
400    #[test]
401    fn releasing_the_last_hold_restarts_the_full_timeout() {
402        let mut a = oled();
403        a.set_hold(HoldReason::Pairing, true, 1_000);
404        assert_eq!(a.set_hold(HoldReason::Pairing, false, 60_000), None);
405        assert_eq!(a.next_deadline(), Some(67_000));
406        assert_eq!(a.poll(66_000), None);
407        assert_eq!(a.poll(67_000), Some(Transition::Dimmed));
408    }
409
410    #[test]
411    fn overlapping_holds_release_independently() {
412        let mut a = oled();
413        a.set_hold(HoldReason::Pairing, true, 1_000);
414        a.set_hold(HoldReason::Alert, true, 2_000);
415        a.set_hold(HoldReason::Pairing, false, 3_000);
416        assert!(a.held());
417        assert_eq!(a.poll(60_000), None);
418        a.set_hold(HoldReason::Alert, false, 4_000);
419        assert!(!a.held());
420        assert_eq!(a.next_deadline(), Some(11_000));
421    }
422
423    #[test]
424    fn redundant_hold_changes_do_not_move_the_clock() {
425        let mut a = oled();
426        a.set_hold(HoldReason::Pairing, true, 1_000);
427        a.set_hold(HoldReason::Pairing, true, 5_000);
428        a.set_hold(HoldReason::Pairing, false, 6_000);
429        // Timer restarts from the real release, not the duplicate assert.
430        assert_eq!(a.next_deadline(), Some(13_000));
431    }
432
433    #[test]
434    fn releasing_a_hold_that_was_never_held_is_inert() {
435        let mut a = oled();
436        assert_eq!(a.set_hold(HoldReason::Maintenance, false, 5_000), None);
437        assert_eq!(a.next_deadline(), Some(7_000));
438    }
439
440    #[test]
441    fn zero_dim_margin_lapses_without_a_dim_state() {
442        let config = AttentionConfig {
443            timeout: Duration::from_secs(10),
444            dim_margin: Duration::ZERO,
445        };
446        let mut a = Attention::new(DisplayKind::Emissive, config, 0);
447        assert_eq!(a.next_deadline(), Some(10_000));
448        assert_eq!(a.poll(9_999), None);
449        assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
450    }
451
452    #[test]
453    fn dim_margin_at_or_over_the_timeout_disables_dimming() {
454        let config = AttentionConfig {
455            timeout: Duration::from_secs(10),
456            dim_margin: Duration::from_secs(10),
457        };
458        let mut a = Attention::new(DisplayKind::Emissive, config, 0);
459        assert_eq!(a.next_deadline(), Some(10_000));
460        assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
461    }
462
463    #[test]
464    fn a_shorter_timeout_applies_from_the_existing_activity_mark() {
465        let mut a = oled();
466        a.set_config(AttentionConfig {
467            timeout: Duration::from_secs(5),
468            dim_margin: Duration::ZERO,
469        });
470        assert_eq!(a.next_deadline(), Some(5_000));
471        assert_eq!(a.poll(5_000), Some(Transition::Lapsed));
472    }
473
474    #[test]
475    fn a_timeout_already_exceeded_lapses_at_the_next_poll() {
476        let mut a = oled();
477        a.wake(100_000);
478        a.set_config(AttentionConfig {
479            timeout: Duration::from_secs(1),
480            dim_margin: Duration::ZERO,
481        });
482        assert_eq!(a.poll(101_500), Some(Transition::Lapsed));
483    }
484}