umsh_ux_tracker/
led.rs

1//! LED sequence engine.
2//!
3//! Owns the heartbeat (always-on; never suppressed) and arbitrates
4//! one-shot sequences layered on top: power-on, power-off, location
5//! advert.
6//!
7//! Pure logic over a monotonic-millisecond clock. The engine reports the
8//! LED state to apply *right now* and the absolute time at which the
9//! caller should re-invoke [`LedEngine::tick`] for the next transition.
10//! No async, no I/O — fully unit-testable with synthetic time.
11//!
12//! # Heartbeat semantics
13//!
14//! The heartbeat is anchored at the engine's start time. After every
15//! `heartbeat_interval`, the LED pulses for `heartbeat_pulse` (defaults:
16//! 2 s and 50 ms). When a one-shot sequence is active it preempts the
17//! heartbeat for the sequence's duration; once the sequence completes
18//! the heartbeat resumes on its original rhythm (i.e. it is *not*
19//! re-anchored), so the user-perceived 2-second cadence stays consistent
20//! across overlays.
21
22use core::time::Duration;
23
24use crate::battery::BatteryState;
25
26/// A one-shot LED flash sequence.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LedSequence {
29    /// One short flash confirming an accepted local action.
30    ActionConfirm,
31    /// 1 s on at boot.
32    PowerOn,
33    /// Three short flashes just before System OFF.
34    PowerOff,
35    /// Quick double-blink on outgoing location advert.
36    LocationAdvert,
37    /// The GNSS receiver was switched on: two pips resolving into a
38    /// long hold.
39    GnssOn,
40    /// The GNSS receiver was switched off: a long hold breaking into
41    /// two pips — the mirror of [`GnssOn`](Self::GnssOn).
42    GnssOff,
43}
44
45impl LedSequence {
46    fn pattern(self) -> &'static Pattern {
47        match self {
48            Self::ActionConfirm => &patterns::ACTION_CONFIRM,
49            Self::PowerOn => &patterns::POWER_ON,
50            Self::PowerOff => &patterns::POWER_OFF,
51            Self::LocationAdvert => &patterns::LOCATION_ADVERT,
52            Self::GnssOn => &patterns::GNSS_ON,
53            Self::GnssOff => &patterns::GNSS_OFF,
54        }
55    }
56}
57
58/// Tunable heartbeat timings.
59#[derive(Debug, Clone, Copy)]
60pub struct LedTimings {
61    pub heartbeat_interval: Duration,
62    pub heartbeat_pulse: Duration,
63}
64
65impl Default for LedTimings {
66    fn default() -> Self {
67        Self {
68            heartbeat_interval: Duration::from_millis(4_000),
69            heartbeat_pulse: Duration::from_millis(20),
70        }
71    }
72}
73
74/// The result of a [`LedEngine::tick`]: the LED state to apply now and
75/// the absolute monotonic-millisecond deadline at which to tick again.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct LedDecision {
78    pub on: bool,
79    pub next_deadline_ms: u64,
80}
81
82/// PWM brightness decision for the T1000-E's state-aware indicator.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct BrightnessDecision {
85    /// Linear brightness in the inclusive range 0..=1000.
86    pub brightness: u16,
87    pub next_deadline_ms: u64,
88}
89
90/// Illuminance at or above which the indicator runs at full brightness:
91/// 10 lux, in millilux.
92pub const DIM_FULL_MLUX: u32 = 10_000;
93
94/// The dimming floor, in permille of normal brightness: 1/20th. Reached
95/// at zero illuminance.
96pub const DIM_MIN_PERMILLE: u16 = 50;
97
98/// Ambient dimming scale, in permille of normal brightness: 1000 at or
99/// above [`DIM_FULL_MLUX`], falling linearly to [`DIM_MIN_PERMILLE`] at
100/// zero. `None` — no reading yet — is full brightness, so a board whose
101/// sensor never answers keeps its ordinary indicator.
102///
103/// A free function rather than an engine method so writers that bypass
104/// the engine (the firmware's BLE status blink) and future consumers of
105/// ambient light apply the same curve.
106pub fn ambient_dim_permille(millilux: Option<u32>) -> u16 {
107    let Some(millilux) = millilux else {
108        return 1_000;
109    };
110    if millilux >= DIM_FULL_MLUX {
111        return 1_000;
112    }
113    let span = u32::from(1_000 - DIM_MIN_PERMILLE);
114    (u32::from(DIM_MIN_PERMILLE) + span * millilux / DIM_FULL_MLUX) as u16
115}
116
117/// Scale a decision's brightness by the ambient dim factor. Deadlines
118/// are untouched: dimming changes how bright a phase is, never when the
119/// next one starts.
120fn dim_decision(decision: BrightnessDecision, dim_permille: u16) -> BrightnessDecision {
121    BrightnessDecision {
122        brightness: ((u32::from(decision.brightness) * u32::from(dim_permille)) / 1_000) as u16,
123        next_deadline_ms: decision.next_deadline_ms,
124    }
125}
126
127/// Scale a binary pulse's duration by the ambient dim factor, floored at
128/// one millisecond so the pulse never disappears entirely.
129///
130/// The heartbeat dims by *shortening* rather than by duty: a brief
131/// full-brightness tick stays legible as a heartbeat in the dark, where
132/// the same energy spread over 20 ms would read as a dim smudge.
133fn dim_pulse_ms(pulse_ms: u64, dim_permille: u16) -> u64 {
134    (pulse_ms * u64::from(dim_permille) / 1_000).max(1)
135}
136
137/// State-aware T1000-E LED policy. One-shot confirmations preempt persistent
138/// state; Charging and Low Battery preempt Attention; Attention replaces only
139/// the ordinary heartbeat.
140#[derive(Debug)]
141pub struct T1000eLedEngine {
142    timings: LedTimings,
143    heartbeat_anchor_ms: u64,
144    battery: BatteryState,
145    attention: bool,
146    /// Most recent ambient light reading, for [`ambient_dim_permille`];
147    /// `None` until one exists.
148    ambient_millilux: Option<u32>,
149    active: Option<ActiveSequence>,
150    /// A running locate alert; see [`LedEngine::start_alert`].
151    alert_since_ms: Option<u64>,
152}
153
154impl T1000eLedEngine {
155    pub fn new(start_ms: u64) -> Self {
156        Self {
157            timings: LedTimings::default(),
158            heartbeat_anchor_ms: start_ms,
159            battery: BatteryState::BatteryOnly,
160            attention: false,
161            ambient_millilux: None,
162            active: None,
163            alert_since_ms: None,
164        }
165    }
166
167    /// Start the locate alert. Idempotent, like [`LedEngine::start_alert`].
168    pub fn start_alert(&mut self, now_ms: u64) {
169        if self.alert_since_ms.is_none() {
170            self.alert_since_ms = Some(now_ms);
171        }
172    }
173
174    /// Stop the locate alert.
175    pub fn stop_alert(&mut self) {
176        self.alert_since_ms = None;
177    }
178
179    /// Whether the locate alert is running.
180    pub fn alert_active(&self) -> bool {
181        self.alert_since_ms.is_some()
182    }
183
184    pub fn set_battery(&mut self, battery: BatteryState) {
185        self.battery = battery;
186    }
187
188    pub fn set_attention(&mut self, attention: bool) {
189        self.attention = attention;
190    }
191
192    /// Feed the most recent ambient light reading. Everything the engine
193    /// shows except the locate alert dims with it — the alert stays at
194    /// full brightness because a radio being searched for values being
195    /// seen over being comfortable, the same priority that lets it
196    /// outrank the critical-battery blackout.
197    pub fn set_ambient_millilux(&mut self, millilux: Option<u32>) {
198        self.ambient_millilux = millilux;
199    }
200
201    pub fn play(&mut self, sequence: LedSequence, now_ms: u64) {
202        self.active = Some(ActiveSequence {
203            pattern: sequence.pattern(),
204            started_at_ms: now_ms,
205        });
206    }
207
208    pub fn tick(&mut self, now_ms: u64) -> BrightnessDecision {
209        // The alert outranks every other LED duty, including the
210        // battery-critical blackout: a radio being searched for shows
211        // itself while it still has the charge to.
212        if let Some(started_at_ms) = self.alert_since_ms {
213            let elapsed = now_ms.saturating_sub(started_at_ms);
214            let cycle_start_ms = now_ms - (elapsed % LedEngine::ALERT_PERIOD_MS);
215            let blink = ActiveSequence {
216                pattern: &patterns::LOCATE,
217                started_at_ms: cycle_start_ms,
218            };
219            return match blink.resolve(now_ms) {
220                Some((on, deadline)) => BrightnessDecision {
221                    brightness: if on { 1_000 } else { 0 },
222                    next_deadline_ms: deadline,
223                },
224                None => BrightnessDecision {
225                    brightness: 0,
226                    next_deadline_ms: cycle_start_ms + LedEngine::ALERT_PERIOD_MS,
227                },
228            };
229        }
230        let dim = ambient_dim_permille(self.ambient_millilux);
231        if let Some(sequence) = &self.active {
232            if let Some((on, deadline)) = sequence.resolve(now_ms) {
233                return dim_decision(
234                    BrightnessDecision {
235                        brightness: if on { 1_000 } else { 0 },
236                        next_deadline_ms: deadline,
237                    },
238                    dim,
239                );
240            }
241            self.active = None;
242        }
243
244        // The binary heartbeats dim by pulse duration (see
245        // [`dim_pulse_ms`]); everything else is a shape whose meaning
246        // lives in its envelope, so those dim by brightness.
247        match self.battery {
248            BatteryState::BatteryCharging => dim_decision(breathing(now_ms, 3_000, 20), dim),
249            BatteryState::BatteryLow => {
250                dim_decision(low_battery(now_ms, self.heartbeat_anchor_ms, 4_000), dim)
251            }
252            BatteryState::BatteryCritical => BrightnessDecision {
253                brightness: 0,
254                next_deadline_ms: now_ms.saturating_add(1_000),
255            },
256            BatteryState::BatteryOnly | BatteryState::BatteryCharged if self.attention => {
257                dim_decision(
258                    attention_pulse(
259                        now_ms,
260                        self.heartbeat_anchor_ms,
261                        self.timings.heartbeat_interval.as_millis() as u64,
262                    ),
263                    dim,
264                )
265            }
266            BatteryState::BatteryCharged => binary_heartbeat(
267                now_ms,
268                self.heartbeat_anchor_ms,
269                self.timings.heartbeat_interval.as_millis() as u64,
270                dim_pulse_ms(60, dim),
271            ),
272            BatteryState::BatteryOnly => binary_heartbeat(
273                now_ms,
274                self.heartbeat_anchor_ms,
275                self.timings.heartbeat_interval.as_millis() as u64,
276                dim_pulse_ms(self.timings.heartbeat_pulse.as_millis() as u64, dim),
277            ),
278        }
279    }
280}
281
282fn breathing(now_ms: u64, cycle_ms: u64, step_ms: u64) -> BrightnessDecision {
283    let phase = now_ms % cycle_ms;
284    let half = cycle_ms / 2;
285    let ramp = if phase < half {
286        phase
287    } else {
288        cycle_ms - phase
289    };
290    BrightnessDecision {
291        brightness: ((1_000 * ramp) / half) as u16,
292        next_deadline_ms: now_ms.saturating_add(step_ms),
293    }
294}
295
296fn attention_pulse(now_ms: u64, anchor_ms: u64, interval_ms: u64) -> BrightnessDecision {
297    const PULSE_MS: u64 = 300;
298    const STEP_MS: u64 = 10;
299    let elapsed = now_ms.saturating_sub(anchor_ms);
300    let phase = elapsed % interval_ms;
301    let cycle_start = now_ms - phase;
302    if phase >= PULSE_MS {
303        return BrightnessDecision {
304            brightness: 0,
305            next_deadline_ms: cycle_start + interval_ms,
306        };
307    }
308    let half = PULSE_MS / 2;
309    let ramp = if phase < half {
310        phase
311    } else {
312        PULSE_MS - phase
313    };
314    BrightnessDecision {
315        brightness: ((1_000 * ramp) / half) as u16,
316        next_deadline_ms: (now_ms + STEP_MS).min(cycle_start + PULSE_MS),
317    }
318}
319
320fn low_battery(now_ms: u64, anchor_ms: u64, interval_ms: u64) -> BrightnessDecision {
321    let elapsed = now_ms.saturating_sub(anchor_ms);
322    let phase = elapsed % interval_ms;
323    let cycle_start = now_ms - phase;
324    let (on, boundary) = match phase {
325        0..100 => (true, 100),
326        100..200 => (false, 200),
327        200..300 => (true, 300),
328        _ => (false, interval_ms),
329    };
330    BrightnessDecision {
331        brightness: if on { 1_000 } else { 0 },
332        next_deadline_ms: cycle_start + boundary,
333    }
334}
335
336fn binary_heartbeat(
337    now_ms: u64,
338    anchor_ms: u64,
339    interval_ms: u64,
340    pulse_ms: u64,
341) -> BrightnessDecision {
342    let elapsed = now_ms.saturating_sub(anchor_ms);
343    let phase = elapsed % interval_ms;
344    let cycle_start = now_ms - phase;
345    if phase < pulse_ms {
346        BrightnessDecision {
347            brightness: 1_000,
348            next_deadline_ms: cycle_start + pulse_ms,
349        }
350    } else {
351        BrightnessDecision {
352            brightness: 0,
353            next_deadline_ms: cycle_start + interval_ms,
354        }
355    }
356}
357
358/// A pre-computed LED pattern. `steps` is an alternating list of
359/// durations starting with the ON phase: `[on_0, off_0, on_1, off_1, ...]`.
360#[derive(Debug)]
361pub struct Pattern {
362    steps: &'static [Duration],
363}
364
365mod patterns {
366    use super::Pattern;
367    use core::time::Duration;
368
369    pub static ACTION_CONFIRM: Pattern = Pattern {
370        steps: &[Duration::from_millis(100)],
371    };
372
373    pub static POWER_ON: Pattern = Pattern {
374        steps: &[Duration::from_millis(1_000)],
375    };
376
377    /// Three flashes: ON 100, OFF 100, ON 100, OFF 100, ON 100.
378    pub static POWER_OFF: Pattern = Pattern {
379        steps: &[
380            Duration::from_millis(100),
381            Duration::from_millis(100),
382            Duration::from_millis(100),
383            Duration::from_millis(100),
384            Duration::from_millis(100),
385        ],
386    };
387
388    /// Quick double-blink: ON 50, OFF 50, ON 50.
389    pub static LOCATION_ADVERT: Pattern = Pattern {
390        steps: &[
391            Duration::from_millis(50),
392            Duration::from_millis(50),
393            Duration::from_millis(50),
394        ],
395    };
396
397    /// Receiver switched on: ON 60, OFF 60, ON 60, OFF 60, ON 500.
398    ///
399    /// The long segment lands *last*, which is the whole design: at a
400    /// glance the operator reads where the light lingers, and a switch
401    /// whose two directions differ only in tempo would be a switch
402    /// nobody can read in daylight from a jacket pocket.
403    pub static GNSS_ON: Pattern = Pattern {
404        steps: &[
405            Duration::from_millis(60),
406            Duration::from_millis(60),
407            Duration::from_millis(60),
408            Duration::from_millis(60),
409            Duration::from_millis(500),
410        ],
411    };
412
413    /// Receiver switched off: ON 500, OFF 60, ON 60, OFF 60, ON 60 —
414    /// [`GNSS_ON`] played backwards, so the light dies away rather than
415    /// settling.
416    pub static GNSS_OFF: Pattern = Pattern {
417        steps: &[
418            Duration::from_millis(500),
419            Duration::from_millis(60),
420            Duration::from_millis(60),
421            Duration::from_millis(60),
422            Duration::from_millis(60),
423        ],
424    };
425
426    /// Locate alert: an urgent triple-blink, replayed on a period by
427    /// [`LedEngine::start_alert`](super::LedEngine::start_alert). On a
428    /// board with no buzzer this *is* the alert, so it is deliberately
429    /// brighter and busier than any other sequence.
430    pub static LOCATE: Pattern = Pattern {
431        steps: &[
432            Duration::from_millis(120),
433            Duration::from_millis(120),
434            Duration::from_millis(120),
435            Duration::from_millis(120),
436            Duration::from_millis(120),
437        ],
438    };
439}
440
441#[derive(Debug)]
442struct ActiveSequence {
443    pattern: &'static Pattern,
444    started_at_ms: u64,
445}
446
447impl ActiveSequence {
448    /// Resolve the current step. Returns `Some((on, end_of_step_ms))` if
449    /// the sequence is still running, `None` if it has completed.
450    fn resolve(&self, now_ms: u64) -> Option<(bool, u64)> {
451        let elapsed = now_ms.saturating_sub(self.started_at_ms);
452        let mut cumulative_ms: u64 = 0;
453        let mut state = true; // patterns start with ON
454        for step in self.pattern.steps {
455            cumulative_ms = cumulative_ms.saturating_add(step.as_millis() as u64);
456            if elapsed < cumulative_ms {
457                return Some((state, self.started_at_ms + cumulative_ms));
458            }
459            state = !state;
460        }
461        None
462    }
463}
464
465/// LED sequence engine.
466#[derive(Debug)]
467pub struct LedEngine {
468    timings: LedTimings,
469    heartbeat_anchor_ms: u64,
470    active: Option<ActiveSequence>,
471    /// When a locate alert started, if one is running. Outranks both the
472    /// one-shot sequences and the heartbeat, and repeats until stopped:
473    /// on a board with no buzzer this is the whole alert.
474    alert_since_ms: Option<u64>,
475}
476
477impl LedEngine {
478    /// Create a new engine anchored at `start_ms`. The first heartbeat
479    /// pulse begins at `start_ms`.
480    pub fn new(timings: LedTimings, start_ms: u64) -> Self {
481        Self {
482            timings,
483            heartbeat_anchor_ms: start_ms,
484            active: None,
485            alert_since_ms: None,
486        }
487    }
488
489    /// An engine for an indicator that idles **dark**: no heartbeat, only
490    /// the locate alert and one-shot sequences.
491    ///
492    /// For a board with two LEDs, where one carries the "this board is
493    /// alive and here is its link state" story and the other is reserved
494    /// for the things meant to catch an eye. A second heartbeat would
495    /// only compete with the first.
496    ///
497    /// Implemented as a zero-length heartbeat pulse, which
498    /// [`heartbeat_decision`](Self::heartbeat_decision) resolves to
499    /// permanently off. The interval then only sets how often the loop
500    /// wakes to find nothing to do, so it is long.
501    pub fn attention_only(start_ms: u64) -> Self {
502        Self::new(
503            LedTimings {
504                heartbeat_interval: Duration::from_secs(60),
505                heartbeat_pulse: Duration::ZERO,
506            },
507            start_ms,
508        )
509    }
510
511    /// How often the locate blink repeats.
512    const ALERT_PERIOD_MS: u64 = 1_500;
513
514    /// Start the locate alert. Idempotent: re-starting a running alert
515    /// keeps its existing rhythm rather than restarting the blink, so a
516    /// host re-arming the deadline does not produce a visible stutter.
517    pub fn start_alert(&mut self, now_ms: u64) {
518        if self.alert_since_ms.is_none() {
519            self.alert_since_ms = Some(now_ms);
520        }
521    }
522
523    /// Stop the locate alert, returning the LED to sequences and the
524    /// heartbeat.
525    pub fn stop_alert(&mut self) {
526        self.alert_since_ms = None;
527    }
528
529    /// Whether the locate alert is running.
530    pub fn alert_active(&self) -> bool {
531        self.alert_since_ms.is_some()
532    }
533
534    /// Start a one-shot sequence, preempting any currently active
535    /// sequence and any in-progress heartbeat pulse.
536    pub fn play(&mut self, seq: LedSequence, now_ms: u64) {
537        self.active = Some(ActiveSequence {
538            pattern: seq.pattern(),
539            started_at_ms: now_ms,
540        });
541    }
542
543    /// Compute the LED state to apply at `now_ms` and the next deadline.
544    pub fn tick(&mut self, now_ms: u64) -> LedDecision {
545        // The alert outranks everything: someone is looking for this
546        // board right now.
547        if let Some(started_at_ms) = self.alert_since_ms {
548            let elapsed = now_ms.saturating_sub(started_at_ms);
549            let cycle_start_ms = now_ms - (elapsed % Self::ALERT_PERIOD_MS);
550            let blink = ActiveSequence {
551                pattern: &patterns::LOCATE,
552                started_at_ms: cycle_start_ms,
553            };
554            return match blink.resolve(now_ms) {
555                Some((on, end_ms)) => LedDecision {
556                    on,
557                    next_deadline_ms: end_ms,
558                },
559                // Past the blink: dark until the next period.
560                None => LedDecision {
561                    on: false,
562                    next_deadline_ms: cycle_start_ms + Self::ALERT_PERIOD_MS,
563                },
564            };
565        }
566        if let Some(seq) = &self.active {
567            if let Some((on, end_ms)) = seq.resolve(now_ms) {
568                return LedDecision {
569                    on,
570                    next_deadline_ms: end_ms,
571                };
572            }
573            self.active = None;
574        }
575        self.heartbeat_decision(now_ms)
576    }
577
578    fn heartbeat_decision(&self, now_ms: u64) -> LedDecision {
579        let interval = self.timings.heartbeat_interval.as_millis() as u64;
580        let pulse = self.timings.heartbeat_pulse.as_millis() as u64;
581        debug_assert!(pulse < interval, "heartbeat pulse must fit inside interval");
582
583        let elapsed = now_ms.saturating_sub(self.heartbeat_anchor_ms);
584        let cycle_start = now_ms - (elapsed % interval);
585        let phase = elapsed % interval;
586
587        // A zero-length pulse is never entered, which is what makes
588        // `attention_only` idle dark.
589        if phase < pulse {
590            LedDecision {
591                on: true,
592                next_deadline_ms: cycle_start + pulse,
593            }
594        } else {
595            LedDecision {
596                on: false,
597                next_deadline_ms: cycle_start + interval,
598            }
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    /// Engine with fixed test timings (2 s interval, 50 ms pulse).
608    ///
609    /// Deliberately *not* `LedTimings::default()`: every assertion below
610    /// hardcodes deadlines derived from these numbers, and the product
611    /// defaults are tuning knobs that have changed before (2000/50 →
612    /// 4000/20 in commit 65d8d4e6, which silently broke this module's
613    /// tests). Pinning the timings here keeps the tests about the engine's
614    /// *logic*, not the current tuning.
615    fn engine(start: u64) -> LedEngine {
616        LedEngine::new(
617            LedTimings {
618                heartbeat_interval: Duration::from_millis(2_000),
619                heartbeat_pulse: Duration::from_millis(50),
620            },
621            start,
622        )
623    }
624
625    #[test]
626    fn heartbeat_starts_on_at_anchor() {
627        let mut e = engine(0);
628        let d = e.tick(0);
629        assert_eq!(
630            d,
631            LedDecision {
632                on: true,
633                next_deadline_ms: 50
634            }
635        );
636    }
637
638    #[test]
639    fn heartbeat_turns_off_after_pulse() {
640        let mut e = engine(0);
641        let d = e.tick(50);
642        assert_eq!(
643            d,
644            LedDecision {
645                on: false,
646                next_deadline_ms: 2_000
647            }
648        );
649    }
650
651    #[test]
652    fn heartbeat_pulses_at_regular_interval() {
653        let mut e = engine(0);
654        // First pulse.
655        assert_eq!(e.tick(0).on, true);
656        assert_eq!(e.tick(49).on, true);
657        // Off gap.
658        assert_eq!(e.tick(50).on, false);
659        assert_eq!(e.tick(1_999).on, false);
660        // Second pulse.
661        let d = e.tick(2_000);
662        assert_eq!(
663            d,
664            LedDecision {
665                on: true,
666                next_deadline_ms: 2_050
667            }
668        );
669        // Off.
670        let d = e.tick(2_050);
671        assert_eq!(
672            d,
673            LedDecision {
674                on: false,
675                next_deadline_ms: 4_000
676            }
677        );
678    }
679
680    #[test]
681    fn heartbeat_handles_skipped_ticks() {
682        // tick() should still return correct phase if called late.
683        let mut e = engine(0);
684        // Skip to mid-pulse of cycle 5: 5*2000 + 20 = 10_020.
685        let d = e.tick(10_020);
686        assert_eq!(
687            d,
688            LedDecision {
689                on: true,
690                next_deadline_ms: 10_050
691            }
692        );
693
694        // Skip to mid-gap of cycle 7: 7*2000 + 500 = 14_500.
695        let d = e.tick(14_500);
696        assert_eq!(
697            d,
698            LedDecision {
699                on: false,
700                next_deadline_ms: 16_000
701            }
702        );
703    }
704
705    #[test]
706    fn power_on_sequence_holds_for_one_second() {
707        let mut e = engine(0);
708        e.play(LedSequence::PowerOn, 100);
709        // During the sequence: ON until 100 + 1000 = 1100.
710        assert_eq!(
711            e.tick(100),
712            LedDecision {
713                on: true,
714                next_deadline_ms: 1_100
715            }
716        );
717        assert_eq!(
718            e.tick(500),
719            LedDecision {
720                on: true,
721                next_deadline_ms: 1_100
722            }
723        );
724        assert_eq!(
725            e.tick(1_099),
726            LedDecision {
727                on: true,
728                next_deadline_ms: 1_100
729            }
730        );
731    }
732
733    #[test]
734    fn power_on_sequence_releases_to_heartbeat() {
735        let mut e = engine(0);
736        e.play(LedSequence::PowerOn, 100);
737        // After the sequence: heartbeat. At t=1100, we're in cycle 0's
738        // off-gap (since first pulse was 0..50). Next ON is 2000.
739        let d = e.tick(1_100);
740        assert_eq!(
741            d,
742            LedDecision {
743                on: false,
744                next_deadline_ms: 2_000
745            }
746        );
747    }
748
749    #[test]
750    fn power_off_sequence_flashes_three_times() {
751        // ON 100, OFF 100, ON 100, OFF 100, ON 100.
752        let mut e = engine(0);
753        e.play(LedSequence::PowerOff, 0);
754        assert_eq!(e.tick(0).on, true); // start of 1st ON
755        assert_eq!(e.tick(99).on, true);
756        assert_eq!(e.tick(100).on, false); // 1st OFF
757        assert_eq!(e.tick(199).on, false);
758        assert_eq!(e.tick(200).on, true); // 2nd ON
759        assert_eq!(e.tick(299).on, true);
760        assert_eq!(e.tick(300).on, false); // 2nd OFF
761        assert_eq!(e.tick(399).on, false);
762        assert_eq!(e.tick(400).on, true); // 3rd ON
763        assert_eq!(e.tick(499).on, true);
764        // After 500ms, sequence done → heartbeat. At t=500 we're past
765        // the heartbeat ON pulse (0..50), in the off-gap → false.
766        assert_eq!(e.tick(500).on, false);
767    }
768
769    #[test]
770    fn sequence_preempts_in_progress_heartbeat_pulse() {
771        // Engine anchored at 0; heartbeat would be ON at t=2000..2050.
772        // Request location advert at t=2010 (during a heartbeat pulse).
773        // The sequence takes over immediately (ON for 50ms).
774        let mut e = engine(0);
775        // Confirm heartbeat is currently ON.
776        assert_eq!(e.tick(2_010).on, true);
777        e.play(LedSequence::LocationAdvert, 2_010);
778        let d = e.tick(2_010);
779        assert_eq!(
780            d,
781            LedDecision {
782                on: true,
783                next_deadline_ms: 2_060
784            }
785        );
786    }
787
788    #[test]
789    fn heartbeat_rhythm_preserved_across_sequence() {
790        // Heartbeat ON expected at t=2000, 4000, 6000, ...
791        let mut e = engine(0);
792        e.play(LedSequence::PowerOn, 100); // 1s sequence
793        // Sequence runs 100..1100; we evaluate after it ends.
794        let _ = e.tick(100);
795        let _ = e.tick(1_100); // clears the sequence
796        // Engine should still align with heartbeat anchor=0, i.e. next
797        // pulse is t=2000, not 1100 + 2000.
798        let d = e.tick(2_000);
799        assert_eq!(
800            d,
801            LedDecision {
802                on: true,
803                next_deadline_ms: 2_050
804            }
805        );
806    }
807
808    #[test]
809    fn sequence_overrides_active_sequence() {
810        // Start PowerOff (3 flashes), then mid-flash request PowerOn.
811        let mut e = engine(0);
812        e.play(LedSequence::PowerOff, 0);
813        assert_eq!(e.tick(50).on, true); // still in 1st PowerOff ON
814        e.play(LedSequence::PowerOn, 50);
815        // PowerOn is 1s ON starting at 50; ends at 1050.
816        let d = e.tick(50);
817        assert_eq!(
818            d,
819            LedDecision {
820                on: true,
821                next_deadline_ms: 1_050
822            }
823        );
824    }
825
826    #[test]
827    fn location_advert_double_blink() {
828        // ON 50, OFF 50, ON 50.
829        let mut e = engine(0);
830        e.play(LedSequence::LocationAdvert, 0);
831        assert_eq!(e.tick(0).on, true);
832        assert_eq!(e.tick(49).on, true);
833        assert_eq!(e.tick(50).on, false);
834        assert_eq!(e.tick(99).on, false);
835        assert_eq!(e.tick(100).on, true);
836        assert_eq!(e.tick(149).on, true);
837        // After 150ms → back to heartbeat (off-gap since past pulse window).
838        assert_eq!(e.tick(150).on, false);
839    }
840
841    #[test]
842    fn alert_blinks_on_its_period_until_stopped() {
843        // LOCATE is ON/OFF/ON/OFF/ON at 120 ms, repeating every 1500 ms.
844        let mut e = engine(0);
845        e.start_alert(0);
846        assert!(e.alert_active());
847        assert_eq!(e.tick(0).on, true);
848        assert_eq!(e.tick(120).on, false);
849        assert_eq!(e.tick(240).on, true);
850        // Dark for the rest of the period...
851        assert_eq!(e.tick(600).on, false);
852        assert_eq!(e.tick(1_499).on, false);
853        // ...then again, indefinitely.
854        assert_eq!(e.tick(1_500).on, true);
855        assert_eq!(e.tick(1_500 * 100).on, true);
856
857        e.stop_alert();
858        assert!(!e.alert_active());
859    }
860
861    #[test]
862    fn alert_outranks_sequences_and_the_heartbeat() {
863        let mut e = engine(0);
864        e.start_alert(0);
865        // A one-shot fired mid-alert must not show through.
866        e.play(LedSequence::PowerOn, 0);
867        assert_eq!(e.tick(600).on, false, "alert gap, not the 1 s PowerOn hold");
868
869        // Stopping the alert releases the LED to what was underneath.
870        e.stop_alert();
871        assert_eq!(e.tick(600).on, true, "the preempted PowerOn resumes");
872    }
873
874    #[test]
875    fn re_starting_a_running_alert_keeps_its_rhythm() {
876        let mut e = engine(0);
877        e.start_alert(0);
878        // A host re-arming the deadline must not restart the blink.
879        e.start_alert(700);
880        assert_eq!(e.tick(1_500).on, true, "still on the original period");
881    }
882
883    #[test]
884    fn attention_replaces_each_heartbeat_with_smooth_300ms_pulse() {
885        let mut e = T1000eLedEngine::new(0);
886        e.set_attention(true);
887        assert_eq!(e.tick(0).brightness, 0);
888        assert!(e.tick(75).brightness > 0);
889        assert_eq!(e.tick(150).brightness, 1_000);
890        assert!(e.tick(225).brightness > 0);
891        assert_eq!(e.tick(300).brightness, 0);
892        assert_eq!(e.tick(4_150).brightness, 1_000);
893    }
894
895    #[test]
896    fn t1000e_alert_outranks_even_a_critical_battery() {
897        let mut e = T1000eLedEngine::new(0);
898        e.set_battery(BatteryState::BatteryCritical);
899        assert_eq!(e.tick(0).brightness, 0, "critical is dark");
900
901        e.start_alert(0);
902        assert_eq!(e.tick(0).brightness, 1_000);
903        assert_eq!(e.tick(120).brightness, 0);
904        assert_eq!(e.tick(240).brightness, 1_000);
905        assert_eq!(e.tick(1_500).brightness, 1_000, "and again next period");
906
907        e.stop_alert();
908        assert_eq!(e.tick(0).brightness, 0, "back to the critical blackout");
909    }
910
911    #[test]
912    fn charging_preempts_attention() {
913        let mut e = T1000eLedEngine::new(0);
914        e.set_attention(true);
915        e.set_battery(BatteryState::BatteryCharging);
916        assert_eq!(e.tick(1_500).brightness, 1_000);
917        assert_eq!(e.tick(3_000).brightness, 0);
918    }
919
920    #[test]
921    fn low_battery_is_a_double_flash() {
922        let mut e = T1000eLedEngine::new(0);
923        e.set_battery(BatteryState::BatteryLow);
924        assert_eq!(e.tick(0).brightness, 1_000);
925        assert_eq!(e.tick(100).brightness, 0);
926        assert_eq!(e.tick(200).brightness, 1_000);
927        assert_eq!(e.tick(300).brightness, 0);
928    }
929
930    #[test]
931    fn ambient_dim_is_linear_between_the_floor_and_full() {
932        assert_eq!(ambient_dim_permille(None), 1_000, "no reading = full");
933        assert_eq!(ambient_dim_permille(Some(DIM_FULL_MLUX)), 1_000);
934        assert_eq!(ambient_dim_permille(Some(u32::MAX)), 1_000);
935        assert_eq!(ambient_dim_permille(Some(0)), DIM_MIN_PERMILLE);
936        // Halfway in lux is halfway between the floor and full.
937        assert_eq!(ambient_dim_permille(Some(DIM_FULL_MLUX / 2)), 525);
938    }
939
940    /// The heartbeat dims by duration, not duty: in darkness the default
941    /// 20 ms pulse becomes a 1 ms full-brightness tick.
942    #[test]
943    fn darkness_shortens_the_heartbeat_pulse() {
944        let mut e = T1000eLedEngine::new(0);
945        e.set_ambient_millilux(Some(0));
946        let d = e.tick(0);
947        assert_eq!(d.brightness, 1_000, "duty stays full");
948        assert_eq!(d.next_deadline_ms, 1, "1/20th of 20 ms, floored at 1");
949        assert_eq!(e.tick(1).brightness, 0);
950    }
951
952    /// PWM envelopes — the charging breathe here — dim by brightness,
953    /// and a one-shot sequence dims the same way.
954    #[test]
955    fn darkness_dims_pwm_envelopes_by_duty() {
956        let mut e = T1000eLedEngine::new(0);
957        e.set_battery(BatteryState::BatteryCharging);
958        assert_eq!(e.tick(1_500).brightness, 1_000, "lit room: full peak");
959        e.set_ambient_millilux(Some(0));
960        assert_eq!(e.tick(1_500).brightness, DIM_MIN_PERMILLE);
961
962        e.play(LedSequence::PowerOn, 3_000);
963        assert_eq!(e.tick(3_000).brightness, DIM_MIN_PERMILLE);
964    }
965
966    /// The locate alert is exempt: a radio being searched for shows
967    /// itself at full brightness however dark the room is.
968    #[test]
969    fn the_locate_alert_ignores_ambient_dimming() {
970        let mut e = T1000eLedEngine::new(0);
971        e.set_ambient_millilux(Some(0));
972        e.start_alert(0);
973        assert_eq!(e.tick(0).brightness, 1_000);
974    }
975
976    /// An attention-only indicator says nothing on its own — including at
977    /// t=0, where an ordinary engine's first heartbeat pulse begins.
978    #[test]
979    fn an_attention_only_engine_idles_dark() {
980        let mut e = LedEngine::attention_only(0);
981        for now in [0, 1, 20, 4_000, 60_000, 123_456] {
982            assert!(!e.tick(now).on, "lit with nothing to say at {now}");
983        }
984    }
985
986    /// It is still an indicator: what it is *for* still reaches it, and
987    /// the alert still outranks a sequence.
988    #[test]
989    fn an_attention_only_engine_still_confirms_and_alerts() {
990        let mut e = LedEngine::attention_only(0);
991        e.play(LedSequence::ActionConfirm, 1_000);
992        assert!(e.tick(1_000).on);
993
994        e.start_alert(2_000);
995        assert!(e.tick(2_000).on);
996        e.stop_alert();
997        assert!(!e.tick(30_000).on);
998    }
999}