1use core::time::Duration;
23
24use crate::battery::BatteryState;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum LedSequence {
29 ActionConfirm,
31 PowerOn,
33 PowerOff,
35 LocationAdvert,
37 GnssOn,
40 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct LedDecision {
78 pub on: bool,
79 pub next_deadline_ms: u64,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct BrightnessDecision {
85 pub brightness: u16,
87 pub next_deadline_ms: u64,
88}
89
90pub const DIM_FULL_MLUX: u32 = 10_000;
93
94pub const DIM_MIN_PERMILLE: u16 = 50;
97
98pub 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
117fn 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
127fn dim_pulse_ms(pulse_ms: u64, dim_permille: u16) -> u64 {
134 (pulse_ms * u64::from(dim_permille) / 1_000).max(1)
135}
136
137#[derive(Debug)]
141pub struct T1000eLedEngine {
142 timings: LedTimings,
143 heartbeat_anchor_ms: u64,
144 battery: BatteryState,
145 attention: bool,
146 ambient_millilux: Option<u32>,
149 active: Option<ActiveSequence>,
150 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 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 pub fn stop_alert(&mut self) {
176 self.alert_since_ms = None;
177 }
178
179 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 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 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 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#[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 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 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 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 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 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 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; 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#[derive(Debug)]
467pub struct LedEngine {
468 timings: LedTimings,
469 heartbeat_anchor_ms: u64,
470 active: Option<ActiveSequence>,
471 alert_since_ms: Option<u64>,
475}
476
477impl LedEngine {
478 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 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 const ALERT_PERIOD_MS: u64 = 1_500;
513
514 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 pub fn stop_alert(&mut self) {
526 self.alert_since_ms = None;
527 }
528
529 pub fn alert_active(&self) -> bool {
531 self.alert_since_ms.is_some()
532 }
533
534 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 pub fn tick(&mut self, now_ms: u64) -> LedDecision {
545 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 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 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 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 assert_eq!(e.tick(0).on, true);
656 assert_eq!(e.tick(49).on, true);
657 assert_eq!(e.tick(50).on, false);
659 assert_eq!(e.tick(1_999).on, false);
660 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 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 let mut e = engine(0);
684 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 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 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 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 let mut e = engine(0);
753 e.play(LedSequence::PowerOff, 0);
754 assert_eq!(e.tick(0).on, true); assert_eq!(e.tick(99).on, true);
756 assert_eq!(e.tick(100).on, false); assert_eq!(e.tick(199).on, false);
758 assert_eq!(e.tick(200).on, true); assert_eq!(e.tick(299).on, true);
760 assert_eq!(e.tick(300).on, false); assert_eq!(e.tick(399).on, false);
762 assert_eq!(e.tick(400).on, true); assert_eq!(e.tick(499).on, true);
764 assert_eq!(e.tick(500).on, false);
767 }
768
769 #[test]
770 fn sequence_preempts_in_progress_heartbeat_pulse() {
771 let mut e = engine(0);
775 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 let mut e = engine(0);
792 e.play(LedSequence::PowerOn, 100); let _ = e.tick(100);
795 let _ = e.tick(1_100); 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 let mut e = engine(0);
812 e.play(LedSequence::PowerOff, 0);
813 assert_eq!(e.tick(50).on, true); e.play(LedSequence::PowerOn, 50);
815 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 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 assert_eq!(e.tick(150).on, false);
839 }
840
841 #[test]
842 fn alert_blinks_on_its_period_until_stopped() {
843 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 assert_eq!(e.tick(600).on, false);
852 assert_eq!(e.tick(1_499).on, false);
853 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 e.play(LedSequence::PowerOn, 0);
867 assert_eq!(e.tick(600).on, false, "alert gap, not the 1 s PowerOn hold");
868
869 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 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 assert_eq!(ambient_dim_permille(Some(DIM_FULL_MLUX / 2)), 525);
938 }
939
940 #[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 #[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 #[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 #[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 #[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}