1use core::time::Duration;
38
39const RAMP_STEP_MS: u64 = 50;
44const RAMP_STEP: Duration = Duration::from_millis(RAMP_STEP_MS);
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum DisplayKind {
49 Emissive,
52 Persistent,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct AttentionConfig {
61 pub timeout: Duration,
63 pub dim_margin: Duration,
67 pub dim_ramp: Duration,
72}
73
74impl AttentionConfig {
75 pub const EMISSIVE: Self = Self {
84 timeout: Duration::from_secs(30),
85 dim_margin: Duration::from_secs(10),
86 dim_ramp: Duration::from_secs(1),
87 };
88
89 pub const PERSISTENT: Self = Self {
95 timeout: Duration::from_secs(30),
96 dim_margin: Duration::ZERO,
97 dim_ramp: Duration::ZERO,
98 };
99
100 fn dim_after(&self, kind: DisplayKind) -> Option<Duration> {
103 if kind != DisplayKind::Emissive
104 || self.dim_margin.is_zero()
105 || self.dim_margin >= self.timeout
106 {
107 return None;
108 }
109 Some(self.timeout - self.dim_margin)
110 }
111
112 fn ramp_steps(&self) -> u16 {
117 let steps = self.dim_ramp.as_millis().div_ceil(u128::from(RAMP_STEP_MS));
118 steps.clamp(1, u128::from(u16::MAX)) as u16
119 }
120}
121
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum HoldReason {
125 Pairing,
128 Alert,
130 Maintenance,
132 Shutdown,
134}
135
136impl HoldReason {
137 const fn bit(self) -> u8 {
138 1 << self as u8
139 }
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum DisplayState {
145 Active,
147 Dim,
150 Lapsed,
153}
154
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub enum Transition {
158 Woke,
165 Dimming,
171 Lapsed,
174}
175
176#[derive(Clone, Copy, Debug)]
182pub struct Attention {
183 kind: DisplayKind,
184 config: AttentionConfig,
185 state: DisplayState,
186 holds: u8,
187 since_ms: u64,
189 ramp_step: u16,
192}
193
194impl Attention {
195 pub fn new(kind: DisplayKind, config: AttentionConfig, now_ms: u64) -> Self {
197 Self {
198 kind,
199 config,
200 state: DisplayState::Active,
201 holds: 0,
202 since_ms: now_ms,
203 ramp_step: 0,
204 }
205 }
206
207 pub fn kind(&self) -> DisplayKind {
208 self.kind
209 }
210
211 pub fn state(&self) -> DisplayState {
212 self.state
213 }
214
215 pub fn config(&self) -> AttentionConfig {
216 self.config
217 }
218
219 pub fn set_config(&mut self, config: AttentionConfig) {
224 self.config = config;
225 }
226
227 pub fn is_lapsed(&self) -> bool {
229 matches!(self.state, DisplayState::Lapsed)
230 }
231
232 pub fn accepts_redraw(&self) -> bool {
240 self.kind == DisplayKind::Persistent || !self.is_lapsed()
241 }
242
243 pub fn held(&self) -> bool {
245 self.holds != 0
246 }
247
248 pub fn brightness_permille(&self) -> u16 {
259 match self.state {
260 DisplayState::Active => 1_000,
261 DisplayState::Dim => {
262 let steps = self.config.ramp_steps();
263 let done = u32::from(self.ramp_step.min(steps));
264 (1_000 - 1_000 * done / u32::from(steps)) as u16
265 }
266 DisplayState::Lapsed => 0,
267 }
268 }
269
270 pub fn wake(&mut self, now_ms: u64) -> Option<Transition> {
277 self.since_ms = now_ms;
278 if matches!(self.state, DisplayState::Active) {
279 return None;
280 }
281 self.state = DisplayState::Active;
282 self.ramp_step = 0;
283 Some(Transition::Woke)
284 }
285
286 pub fn set_hold(
293 &mut self,
294 reason: HoldReason,
295 active: bool,
296 now_ms: u64,
297 ) -> Option<Transition> {
298 let before = self.holds;
299 if active {
300 self.holds |= reason.bit();
301 } else {
302 self.holds &= !reason.bit();
303 }
304 if self.holds == before {
305 return None;
306 }
307 if active {
308 return self.wake(now_ms);
309 }
310 if self.holds == 0 {
311 self.since_ms = now_ms;
312 }
313 None
314 }
315
316 pub fn poll(&mut self, now_ms: u64) -> Option<Transition> {
318 if self.held() || self.is_lapsed() {
319 return None;
320 }
321 let idle = Duration::from_millis(now_ms.saturating_sub(self.since_ms));
322 if idle >= self.config.timeout {
323 self.state = DisplayState::Lapsed;
324 return Some(Transition::Lapsed);
325 }
326 let dim_after = self.config.dim_after(self.kind)?;
327 if idle < dim_after {
328 return None;
329 }
330 let steps = self.config.ramp_steps();
335 let elapsed = (idle - dim_after).as_millis() as u64;
336 let due = ((elapsed / RAMP_STEP_MS) + 1).min(u64::from(steps)) as u16;
337 if matches!(self.state, DisplayState::Active) {
338 self.state = DisplayState::Dim;
339 } else if due <= self.ramp_step {
340 return None;
341 }
342 self.ramp_step = due;
343 Some(Transition::Dimming)
344 }
345
346 pub fn next_deadline(&self) -> Option<u64> {
349 if self.held() || self.is_lapsed() {
350 return None;
351 }
352 let lapse_at = self
353 .since_ms
354 .saturating_add(self.config.timeout.as_millis() as u64);
355 let after = match self.state {
356 DisplayState::Active => self
357 .config
358 .dim_after(self.kind)
359 .unwrap_or(self.config.timeout),
360 DisplayState::Dim => match self.config.dim_after(self.kind) {
363 Some(dim_after) if self.ramp_step < self.config.ramp_steps() => {
364 dim_after + RAMP_STEP * u32::from(self.ramp_step)
365 }
366 _ => return Some(lapse_at),
367 },
368 DisplayState::Lapsed => return None,
369 };
370 Some(
371 self.since_ms
372 .saturating_add(after.as_millis() as u64)
373 .min(lapse_at),
374 )
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 fn oled() -> Attention {
383 Attention::new(DisplayKind::Emissive, AttentionConfig::EMISSIVE, 0)
384 }
385
386 fn epaper() -> Attention {
387 Attention::new(DisplayKind::Persistent, AttentionConfig::PERSISTENT, 0)
388 }
389
390 fn ramp(a: &mut Attention, until_ms: u64) -> (usize, u64, u64) {
395 let (mut count, mut first, mut last) = (0, 0, 0);
396 while let Some(deadline) = a.next_deadline() {
397 if deadline > until_ms {
398 break;
399 }
400 if let Some(transition) = a.poll(deadline) {
401 assert_eq!(transition, Transition::Dimming, "at {deadline}");
402 if count == 0 {
403 first = deadline;
404 }
405 last = deadline;
406 count += 1;
407 }
408 }
409 (count, first, last)
410 }
411
412 #[test]
413 fn emissive_dims_then_lapses() {
414 let mut a = oled();
415 assert_eq!(a.next_deadline(), Some(20_000));
416 assert_eq!(a.poll(19_999), None);
417 assert_eq!(a.poll(20_000), Some(Transition::Dimming));
418 assert_eq!(a.state(), DisplayState::Dim);
419
420 ramp(&mut a, 29_999);
422 assert_eq!(a.brightness_permille(), 0);
423 assert_eq!(a.next_deadline(), Some(30_000));
424 assert_eq!(a.poll(29_999), None);
425 assert_eq!(a.poll(30_000), Some(Transition::Lapsed));
426 assert_eq!(a.state(), DisplayState::Lapsed);
427 assert_eq!(a.next_deadline(), None);
428 }
429
430 #[test]
433 fn the_fall_into_the_dim_state_is_stepped() {
434 let mut a = oled();
435 let (count, first, last) = ramp(&mut a, 29_999);
436 assert_eq!(count, 20, "one second of 50 ms steps");
437 assert_eq!(first, 20_000);
439 assert_eq!(last, 20_950);
440 }
441
442 #[test]
443 fn brightness_falls_monotonically_to_the_floor() {
444 let mut a = oled();
445 assert_eq!(a.brightness_permille(), 1_000);
446 let mut previous = 1_000;
447 while let Some(deadline) = a.next_deadline() {
448 if deadline >= 30_000 {
449 break;
450 }
451 a.poll(deadline);
452 let now = a.brightness_permille();
453 assert!(now < previous, "{now} is not below {previous}");
454 previous = now;
455 }
456 assert_eq!(previous, 0);
457 a.poll(30_000);
458 assert_eq!(a.brightness_permille(), 0);
459 }
460
461 #[test]
465 fn a_late_poll_catches_up_in_one_step() {
466 let mut a = oled();
467 assert_eq!(a.poll(20_500), Some(Transition::Dimming));
468 assert_eq!(a.brightness_permille(), 450);
469 assert_eq!(a.poll(20_950), Some(Transition::Dimming));
470 assert_eq!(a.brightness_permille(), 0);
471 }
472
473 #[test]
474 fn the_ramp_stops_at_the_floor() {
475 let mut a = oled();
476 ramp(&mut a, 29_999);
477 assert_eq!(a.poll(25_000), None);
478 assert_eq!(a.brightness_permille(), 0);
479 }
480
481 #[test]
482 fn a_wake_mid_ramp_returns_to_full_brightness() {
483 let mut a = oled();
484 a.poll(20_200);
485 assert_eq!(a.state(), DisplayState::Dim);
486 assert!(a.brightness_permille() < 1_000);
487 assert_eq!(a.wake(20_300), Some(Transition::Woke));
488 assert_eq!(a.brightness_permille(), 1_000);
489 assert_eq!(a.next_deadline(), Some(40_300));
490 }
491
492 #[test]
493 fn a_panel_that_cannot_fade_drops_in_one_step() {
494 let config = AttentionConfig {
495 dim_ramp: Duration::ZERO,
496 ..AttentionConfig::EMISSIVE
497 };
498 let mut a = Attention::new(DisplayKind::Emissive, config, 0);
499 assert_eq!(a.poll(20_000), Some(Transition::Dimming));
500 assert_eq!(a.brightness_permille(), 0);
501 assert_eq!(a.next_deadline(), Some(30_000));
502 assert_eq!(a.poll(25_000), None);
503 }
504
505 #[test]
506 fn no_deadline_ever_overshoots_the_lapse() {
507 let config = AttentionConfig {
509 timeout: Duration::from_secs(10),
510 dim_margin: Duration::from_secs(1),
511 dim_ramp: Duration::from_secs(5),
512 };
513 let mut a = Attention::new(DisplayKind::Emissive, config, 0);
514 while let Some(deadline) = a.next_deadline() {
515 assert!(deadline <= 10_000, "{deadline} is past the lapse");
516 if a.poll(deadline) == Some(Transition::Lapsed) {
517 break;
518 }
519 }
520 assert!(a.is_lapsed());
521 }
522
523 #[test]
524 fn lapse_fires_once() {
525 let mut a = oled();
526 a.poll(20_000);
527 assert_eq!(a.poll(30_000), Some(Transition::Lapsed));
528 assert_eq!(a.poll(40_000), None);
529 }
530
531 #[test]
532 fn persistent_lapses_without_dimming() {
533 let mut a = epaper();
534 assert_eq!(a.next_deadline(), Some(30_000));
535 assert_eq!(a.poll(29_999), None);
536 assert_eq!(a.poll(30_000), Some(Transition::Lapsed));
537 assert_eq!(a.state(), DisplayState::Lapsed);
538 }
539
540 #[test]
541 fn persistent_always_accepts_redraw() {
542 let mut a = epaper();
543 assert!(a.accepts_redraw());
544 a.poll(30_000);
545 assert!(a.is_lapsed());
546 assert!(a.accepts_redraw());
547 }
548
549 #[test]
550 fn dark_emissive_panel_refuses_redraw() {
551 let mut a = oled();
552 assert!(a.accepts_redraw());
553 a.poll(20_000);
554 assert!(a.accepts_redraw());
556 a.poll(30_000);
557 assert!(!a.accepts_redraw());
558 }
559
560 #[test]
561 fn wake_from_lapsed_reports_the_transition() {
562 let mut a = oled();
563 a.poll(30_000);
564 assert_eq!(a.wake(32_000), Some(Transition::Woke));
565 assert_eq!(a.state(), DisplayState::Active);
566 assert_eq!(a.next_deadline(), Some(52_000));
568 }
569
570 #[test]
571 fn wake_from_dim_restores_full_brightness() {
572 let mut a = oled();
573 a.poll(20_000);
574 assert_eq!(a.state(), DisplayState::Dim);
575 assert_eq!(a.wake(21_000), Some(Transition::Woke));
576 assert_eq!(a.state(), DisplayState::Active);
577 assert_eq!(a.brightness_permille(), 1_000);
578 }
579
580 #[test]
581 fn wake_while_active_only_defers_the_deadline() {
582 let mut a = oled();
583 assert_eq!(a.wake(5_000), None);
584 assert_eq!(a.next_deadline(), Some(25_000));
585 assert_eq!(a.poll(20_000), None);
586 }
587
588 #[test]
589 fn a_hold_pins_the_display_awake() {
590 let mut a = oled();
591 a.set_hold(HoldReason::Pairing, true, 1_000);
592 assert_eq!(a.next_deadline(), None);
593 assert_eq!(a.poll(60_000), None);
594 assert_eq!(a.state(), DisplayState::Active);
595 }
596
597 #[test]
598 fn asserting_a_hold_wakes_a_lapsed_panel() {
599 let mut a = oled();
600 a.poll(30_000);
601 assert!(a.is_lapsed());
602 assert_eq!(
603 a.set_hold(HoldReason::Alert, true, 31_000),
604 Some(Transition::Woke)
605 );
606 assert_eq!(a.state(), DisplayState::Active);
607 }
608
609 #[test]
610 fn releasing_the_last_hold_restarts_the_full_timeout() {
611 let mut a = oled();
612 a.set_hold(HoldReason::Pairing, true, 1_000);
613 assert_eq!(a.set_hold(HoldReason::Pairing, false, 60_000), None);
614 assert_eq!(a.next_deadline(), Some(80_000));
615 assert_eq!(a.poll(79_000), None);
616 assert_eq!(a.poll(80_000), Some(Transition::Dimming));
617 }
618
619 #[test]
620 fn overlapping_holds_release_independently() {
621 let mut a = oled();
622 a.set_hold(HoldReason::Pairing, true, 1_000);
623 a.set_hold(HoldReason::Alert, true, 2_000);
624 a.set_hold(HoldReason::Pairing, false, 3_000);
625 assert!(a.held());
626 assert_eq!(a.poll(60_000), None);
627 a.set_hold(HoldReason::Alert, false, 4_000);
628 assert!(!a.held());
629 assert_eq!(a.next_deadline(), Some(24_000));
630 }
631
632 #[test]
633 fn redundant_hold_changes_do_not_move_the_clock() {
634 let mut a = oled();
635 a.set_hold(HoldReason::Pairing, true, 1_000);
636 a.set_hold(HoldReason::Pairing, true, 5_000);
637 a.set_hold(HoldReason::Pairing, false, 6_000);
638 assert_eq!(a.next_deadline(), Some(26_000));
640 }
641
642 #[test]
643 fn releasing_a_hold_that_was_never_held_is_inert() {
644 let mut a = oled();
645 assert_eq!(a.set_hold(HoldReason::Maintenance, false, 5_000), None);
646 assert_eq!(a.next_deadline(), Some(20_000));
647 }
648
649 #[test]
650 fn zero_dim_margin_lapses_without_a_dim_state() {
651 let config = AttentionConfig {
652 timeout: Duration::from_secs(10),
653 dim_margin: Duration::ZERO,
654 ..AttentionConfig::EMISSIVE
655 };
656 let mut a = Attention::new(DisplayKind::Emissive, config, 0);
657 assert_eq!(a.next_deadline(), Some(10_000));
658 assert_eq!(a.poll(9_999), None);
659 assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
660 }
661
662 #[test]
663 fn dim_margin_at_or_over_the_timeout_disables_dimming() {
664 let config = AttentionConfig {
665 timeout: Duration::from_secs(10),
666 dim_margin: Duration::from_secs(10),
667 ..AttentionConfig::EMISSIVE
668 };
669 let mut a = Attention::new(DisplayKind::Emissive, config, 0);
670 assert_eq!(a.next_deadline(), Some(10_000));
671 assert_eq!(a.poll(10_000), Some(Transition::Lapsed));
672 }
673
674 #[test]
675 fn a_shorter_timeout_applies_from_the_existing_activity_mark() {
676 let mut a = oled();
677 a.set_config(AttentionConfig {
678 timeout: Duration::from_secs(5),
679 dim_margin: Duration::ZERO,
680 ..AttentionConfig::EMISSIVE
681 });
682 assert_eq!(a.next_deadline(), Some(5_000));
683 assert_eq!(a.poll(5_000), Some(Transition::Lapsed));
684 }
685
686 #[test]
687 fn a_timeout_already_exceeded_lapses_at_the_next_poll() {
688 let mut a = oled();
689 a.wake(100_000);
690 a.set_config(AttentionConfig {
691 timeout: Duration::from_secs(1),
692 dim_margin: Duration::ZERO,
693 ..AttentionConfig::EMISSIVE
694 });
695 assert_eq!(a.poll(101_500), Some(Transition::Lapsed));
696 }
697}