1use crate::color;
2use crate::light::command::LightCommand;
3use crate::light::LightOutput;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ColorMode {
8 HueSaturation,
9 XY,
10 ColorTemperature,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct LightSnapshot {
27 pub on: bool,
28 pub brightness: u8,
31 pub rendered_brightness: u8,
35 pub color_mode: ColorMode,
36 pub hue: u8,
37 pub saturation: u8,
38 pub enhanced_hue: u16,
39 pub color_x: u16,
40 pub color_y: u16,
41 pub color_temp_mireds: u16,
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub struct SceneState {
47 pub on: Option<bool>,
48 pub brightness: Option<u8>,
49 pub color_mode: Option<ColorMode>,
50 pub hue: Option<u8>,
51 pub saturation: Option<u8>,
52 pub enhanced_hue: Option<u16>,
53 pub color_x: Option<u16>,
54 pub color_y: Option<u16>,
55 pub color_temp_mireds: Option<u16>,
56}
57
58#[derive(Debug, Clone, Copy, Default, PartialEq)]
59struct TransitionFields {
60 brightness: bool,
61 render_brightness: bool,
62 color_x: bool,
63 color_y: bool,
64 color_temp_mireds: bool,
65 hue: bool,
66 saturation: bool,
67}
68
69impl TransitionFields {
70 fn any(self) -> bool {
71 self.brightness
72 || self.render_brightness
73 || self.color_x
74 || self.color_y
75 || self.color_temp_mireds
76 || self.hue
77 || self.saturation
78 }
79}
80
81pub struct LightState {
83 pub on: bool,
84 pub brightness: u8,
85 pub color_mode: ColorMode,
86 pub hue: u8,
87 pub saturation: u8,
88 pub color_x: u16,
89 pub color_y: u16,
90 pub color_temp_mireds: u16,
91 render_brightness: u8,
92 output: Box<dyn LightOutput>,
93
94 pending_color_x: Option<u16>,
97 pending_color_y: Option<u16>,
98
99 identify_blink_count: u32,
102 identify_blink_phase: bool,
103
104 transition_active: bool,
105 transition_duration_ms: u32,
106 transition_elapsed_ms: u32,
107 transition_step_interval_ms: u16,
108 transition_fields: TransitionFields,
109 transition_final_on: Option<bool>,
110 start_brightness: u8,
111 start_render_brightness: u8,
112 start_color_x: u16,
113 start_color_y: u16,
114 start_color_temp_mireds: u16,
115 start_hue: u8,
116 start_saturation: u8,
117
118 target_brightness: u8,
119 target_render_brightness: u8,
120 target_color_x: u16,
121 target_color_y: u16,
122 target_color_temp_mireds: u16,
123 target_hue: u8,
124 target_saturation: u8,
125}
126
127impl LightState {
128 pub fn new(output: Box<dyn LightOutput>) -> Self {
129 Self {
130 on: false,
131 brightness: 254,
132 color_mode: ColorMode::ColorTemperature,
133 hue: 0,
134 saturation: 0,
135 color_x: 20495,
136 color_y: 21561,
137 color_temp_mireds: 370,
138 render_brightness: 0,
139 output,
140 pending_color_x: None,
141 pending_color_y: None,
142 identify_blink_count: 0,
143 identify_blink_phase: false,
144 transition_active: false,
145 transition_duration_ms: 0,
146 transition_elapsed_ms: 0,
147 transition_step_interval_ms: 50,
148 transition_fields: TransitionFields::default(),
149 transition_final_on: None,
150 start_brightness: 254,
151 start_render_brightness: 0,
152 start_color_x: 20495,
153 start_color_y: 21561,
154 start_color_temp_mireds: 370,
155 start_hue: 0,
156 start_saturation: 0,
157 target_brightness: 254,
158 target_render_brightness: 0,
159 target_color_x: 20495,
160 target_color_y: 21561,
161 target_color_temp_mireds: 370,
162 target_hue: 0,
163 target_saturation: 0,
164 }
165 }
166
167 pub fn new_powered_on(output: Box<dyn LightOutput>) -> Self {
169 let mut state = Self::new(output);
170 state.on = true;
171 state.render_brightness = state.brightness;
172 state.output.set_on(true);
173 state.render_output();
174 state.sync_transition_targets_to_current();
175 state
176 }
177
178 pub fn light_snapshot(&self) -> LightSnapshot {
180 LightSnapshot {
181 on: self.on,
182 brightness: self.brightness,
183 rendered_brightness: if self.on { self.render_brightness } else { 0 },
184 color_mode: self.color_mode,
185 hue: self.hue,
186 saturation: self.saturation,
187 enhanced_hue: color::hue_to_enhanced_hue(self.hue),
188 color_x: self.color_x,
189 color_y: self.color_y,
190 color_temp_mireds: self.color_temp_mireds,
191 }
192 }
193
194 pub fn scene_snapshot(&self) -> SceneState {
196 SceneState {
197 on: Some(self.on),
198 brightness: Some(self.brightness),
199 color_mode: Some(self.color_mode),
200 hue: Some(self.hue),
201 saturation: Some(self.saturation),
202 enhanced_hue: Some(color::hue_to_enhanced_hue(self.hue)),
203 color_x: Some(self.color_x),
204 color_y: Some(self.color_y),
205 color_temp_mireds: Some(self.color_temp_mireds),
206 }
207 }
208
209 pub fn apply_scene_state(&mut self, scene: &SceneState) {
210 self.apply_scene_state_with_transition(scene, 0);
211 }
212
213 pub fn apply_scene_state_with_transition(
214 &mut self,
215 scene: &SceneState,
216 transition_time_tenths: u16,
217 ) {
218 self.cancel_transition();
219
220 if transition_time_tenths > 0 {
221 self.pending_color_x = None;
222 self.pending_color_y = None;
223
224 if scene.on == Some(true) && !self.on {
225 self.on = true;
226 self.output.set_on(true);
227 self.render_brightness = 0;
228 }
229
230 let final_on = if scene.on == Some(false) {
231 Some(false)
232 } else {
233 None
234 };
235 let render_target = if scene.on == Some(false) {
236 Some(0)
237 } else if scene.on == Some(true) || scene.brightness.is_some() {
238 Some(scene.brightness.unwrap_or(self.brightness))
239 } else {
240 None
241 };
242
243 self.start_transition_with_render(
244 scene.brightness,
245 scene.color_x,
246 scene.color_y,
247 scene.color_temp_mireds,
248 scene
249 .enhanced_hue
250 .map(color::enhanced_hue_to_hue)
251 .or(scene.hue),
252 scene.saturation,
253 render_target,
254 final_on,
255 transition_time_tenths,
256 );
257
258 if let Some(mode) = scene.color_mode {
259 if mode != self.color_mode {
267 self.color_mode = mode;
268 self.render_output();
269 }
270 }
271
272 #[cfg(debug_assertions)]
273 self.validate_color_state();
274 return;
275 }
276
277 if let Some(level) = scene.brightness {
278 self.brightness = level.min(254);
279 }
280
281 if let Some(x) = scene.color_x {
282 self.color_x = x;
283 self.color_mode = ColorMode::XY;
284 self.pending_color_x = None;
285 self.pending_color_y = None;
286 }
287 if let Some(y) = scene.color_y {
288 self.color_y = y;
289 self.color_mode = ColorMode::XY;
290 self.pending_color_x = None;
291 self.pending_color_y = None;
292 }
293
294 if let Some(temp) = scene.color_temp_mireds {
295 self.color_temp_mireds = temp;
296 self.color_mode = ColorMode::ColorTemperature;
297 self.pending_color_x = None;
298 self.pending_color_y = None;
299 }
300
301 if let Some(enhanced_hue) = scene.enhanced_hue {
302 self.hue = color::enhanced_hue_to_hue(enhanced_hue);
303 self.color_mode = ColorMode::HueSaturation;
304 self.pending_color_x = None;
305 self.pending_color_y = None;
306 } else if let Some(hue) = scene.hue {
307 self.hue = hue.min(254);
308 self.color_mode = ColorMode::HueSaturation;
309 self.pending_color_x = None;
310 self.pending_color_y = None;
311 }
312
313 if let Some(saturation) = scene.saturation {
314 self.saturation = saturation.min(254);
315 self.color_mode = ColorMode::HueSaturation;
316 self.pending_color_x = None;
317 self.pending_color_y = None;
318 }
319
320 if let Some(mode) = scene.color_mode {
321 self.color_mode = mode;
322 if mode != ColorMode::XY {
323 self.pending_color_x = None;
324 self.pending_color_y = None;
325 }
326 }
327
328 if let Some(on) = scene.on {
329 self.on = on;
330 if on {
331 self.render_brightness = self.brightness;
332 self.output.set_on(true);
333 } else {
334 self.render_brightness = 0;
335 }
336 }
337 if scene.on.is_none() && self.on && scene.brightness.is_some() {
338 self.render_brightness = self.brightness;
339 }
340
341 self.render_output();
342 if !self.on {
343 self.output.set_on(false);
344 }
345 self.sync_transition_targets_to_current();
346
347 #[cfg(debug_assertions)]
348 self.validate_color_state();
349 }
350
351 pub fn apply_command(&mut self, cmd: &LightCommand) {
352 match cmd {
353 LightCommand::On => {
354 self.fade_on(crate::light::command::DEFAULT_ON_OFF_TRANSITION_TENTHS);
355 }
356 LightCommand::Off => {
357 self.fade_off(crate::light::command::DEFAULT_ON_OFF_TRANSITION_TENTHS);
358 }
359 LightCommand::Toggle => {
360 if self.on {
361 self.fade_off(crate::light::command::DEFAULT_ON_OFF_TRANSITION_TENTHS);
362 } else {
363 self.fade_on(crate::light::command::DEFAULT_ON_OFF_TRANSITION_TENTHS);
364 }
365 }
366 LightCommand::SetLevel {
367 level,
368 transition_time,
369 } => {
370 if *level > 0 && !self.on {
371 self.on = true;
372 self.output.set_on(true);
373 self.render_brightness = 0;
374 }
375 self.start_transition(Some(*level), None, None, None, None, None, *transition_time);
376 }
377 LightCommand::MoveToColor {
378 x,
379 y,
380 transition_time,
381 } => {
382 if let Some(x) = x {
383 self.pending_color_x = Some(*x);
384 }
385 if let Some(y) = y {
386 self.pending_color_y = Some(*y);
387 }
388
389 let have_both = self.pending_color_x.is_some() && self.pending_color_y.is_some();
390 let command_had_both = x.is_some() && y.is_some();
391
392 if have_both || command_had_both {
393 self.start_transition(
394 None,
395 self.pending_color_x,
396 self.pending_color_y,
397 None,
398 None,
399 None,
400 *transition_time,
401 );
402 self.pending_color_x = None;
403 self.pending_color_y = None;
404 }
405 }
406 LightCommand::MoveToColorTemp {
407 mireds,
408 transition_time,
409 } => {
410 self.pending_color_x = None;
411 self.pending_color_y = None;
412 self.start_transition(
413 None,
414 None,
415 None,
416 Some(*mireds),
417 None,
418 None,
419 *transition_time,
420 );
421 }
422 LightCommand::MoveToHueAndSaturation {
423 hue,
424 saturation,
425 transition_time,
426 } => {
427 self.pending_color_x = None;
428 self.pending_color_y = None;
429 self.start_transition(None, None, None, None, *hue, *saturation, *transition_time);
430 }
431 LightCommand::EnhancedMoveToHueAndSaturation {
432 enhanced_hue,
433 saturation,
434 transition_time,
435 } => {
436 self.pending_color_x = None;
437 self.pending_color_y = None;
438 let hue = color::enhanced_hue_to_hue(*enhanced_hue);
439 self.start_transition(
440 None,
441 None,
442 None,
443 None,
444 Some(hue),
445 *saturation,
446 *transition_time,
447 );
448 }
449 LightCommand::Identify { duration } => {
450 self.pending_color_x = None;
451 self.pending_color_y = None;
452 if *duration == 0 {
453 if self.identify_blink_count > 0 {
454 self.identify_blink_count = 0;
455 self.output.set_on(self.on);
456 }
457 } else {
458 let half_cycles = u32::from(*duration) * 2;
461 log::info!(
462 "Identify: {} seconds ({} half-cycles)",
463 duration,
464 half_cycles
465 );
466 self.identify_blink_count = half_cycles;
467 self.identify_blink_phase = false;
468 self.output.set_on(false);
469 }
470 }
471 LightCommand::TriggerEffect { effect_id, .. } => {
472 match effect_id {
475 0xFF => {
476 if self.identify_blink_count > 0 {
478 self.identify_blink_count = 0;
479 self.output.set_on(self.on);
480 }
481 }
482 0xFE => {
483 }
485 0x00 => {
486 self.identify_blink_count = 2;
489 self.identify_blink_phase = false;
490 self.output.set_on(false);
491 }
492 _ => {
493 self.identify_blink_count = 4;
501 self.identify_blink_phase = false;
502 self.output.set_on(false);
503 }
504 }
505 }
506 LightCommand::StopMoveStep => {
507 self.cancel_transition();
508 self.pending_color_x = None;
509 self.pending_color_y = None;
510 }
511 }
512
513 #[cfg(debug_assertions)]
514 self.validate_color_state();
515 }
516
517 fn apply_current_color_at(&mut self, brightness: u8) {
518 if !self.on {
519 return;
520 }
521
522 let (r, g, b) = match self.color_mode {
523 ColorMode::XY => color::xy_to_rgb(self.color_x, self.color_y, brightness),
524 ColorMode::ColorTemperature => {
525 color::scale_rgb(color::mireds_to_rgb(self.color_temp_mireds), brightness)
526 }
527 ColorMode::HueSaturation => color::hsv_to_rgb(self.hue, self.saturation, brightness),
528 };
529
530 self.output.set_rgb(r, g, b);
531 }
532
533 fn render_output(&mut self) {
534 let brightness = if self.on { self.render_brightness } else { 0 };
535 self.output.set_brightness(brightness);
536 if brightness == 0 {
537 self.output.set_rgb(0, 0, 0);
538 } else if self.color_mode == ColorMode::ColorTemperature {
539 self.output.set_color_temp(self.color_temp_mireds);
540 } else {
541 self.apply_current_color_at(brightness);
542 }
543
544 let snapshot = self.light_snapshot();
546 self.output.state_update(&snapshot);
547 }
548
549 #[cfg(debug_assertions)]
550 fn validate_color_state(&self) {
551 match self.color_mode {
552 ColorMode::XY => {
553 if self.color_x == 0 && self.color_y == 0 {
554 log::warn!("validate: XY mode but both color_x/y are zero");
555 }
556 }
557 ColorMode::ColorTemperature => {
558 if self.color_temp_mireds < 153 || self.color_temp_mireds > 500 {
559 log::warn!(
560 "validate: ColorTemperature mireds out of range: {}",
561 self.color_temp_mireds
562 );
563 }
564 }
565 ColorMode::HueSaturation => {}
566 }
567 }
568
569 #[allow(clippy::too_many_arguments)]
572 pub fn start_transition(
573 &mut self,
574 brightness: Option<u8>,
575 color_x: Option<u16>,
576 color_y: Option<u16>,
577 color_temp: Option<u16>,
578 hue: Option<u8>,
579 saturation: Option<u8>,
580 transition_time_tenths: u16,
581 ) {
582 self.start_transition_with_render(
583 brightness,
584 color_x,
585 color_y,
586 color_temp,
587 hue,
588 saturation,
589 None,
590 None,
591 transition_time_tenths,
592 );
593 }
594
595 #[allow(clippy::too_many_arguments)]
596 fn start_transition_with_render(
597 &mut self,
598 brightness: Option<u8>,
599 color_x: Option<u16>,
600 color_y: Option<u16>,
601 color_temp: Option<u16>,
602 hue: Option<u8>,
603 saturation: Option<u8>,
604 render_brightness: Option<u8>,
605 final_on: Option<bool>,
606 transition_time_tenths: u16,
607 ) {
608 let brightness = brightness.map(|b| b.min(254));
613 let hue = hue.map(|h| h.min(254));
614 let saturation = saturation.map(|s| s.min(254));
615 let render_brightness = render_brightness.map(|b| b.min(254));
616
617 let render_brightness = render_brightness.or_else(|| {
618 if brightness.is_some() && self.on {
619 brightness
620 } else {
621 None
622 }
623 });
624 let fields = TransitionFields {
625 brightness: brightness.is_some(),
626 render_brightness: render_brightness.is_some(),
627 color_x: color_x.is_some(),
628 color_y: color_y.is_some(),
629 color_temp_mireds: color_temp.is_some(),
630 hue: hue.is_some(),
631 saturation: saturation.is_some(),
632 };
633
634 if !fields.any() {
635 if let Some(final_on) = final_on {
636 self.on = final_on;
637 self.output.set_on(final_on);
638 }
639 return;
640 }
641
642 self.cancel_transition();
643
644 if transition_time_tenths == 0 {
645 if let Some(b) = brightness {
646 self.brightness = b;
647 }
648 if let Some(b) = render_brightness {
649 self.render_brightness = b;
650 }
651 if let Some(x) = color_x {
652 self.color_x = x;
653 self.color_mode = ColorMode::XY;
654 }
655 if let Some(y) = color_y {
656 self.color_y = y;
657 self.color_mode = ColorMode::XY;
658 }
659 if let Some(t) = color_temp {
660 self.color_temp_mireds = t;
661 self.color_mode = ColorMode::ColorTemperature;
662 }
663 if let Some(h) = hue {
664 self.hue = h;
665 self.color_mode = ColorMode::HueSaturation;
666 }
667 if let Some(s) = saturation {
668 self.saturation = s;
669 self.color_mode = ColorMode::HueSaturation;
670 }
671
672 if final_on == Some(true) && !self.on {
673 self.on = true;
674 self.output.set_on(true);
675 }
676 self.render_output();
677 if final_on == Some(false) {
678 self.on = false;
679 self.output.set_on(false);
680 }
681 self.sync_transition_targets_to_current();
682 return;
683 }
684
685 self.start_brightness = self.brightness;
686 self.start_render_brightness = self.render_brightness;
687 self.start_color_x = self.color_x;
688 self.start_color_y = self.color_y;
689 self.start_color_temp_mireds = self.color_temp_mireds;
690 self.start_hue = self.hue;
691 self.start_saturation = self.saturation;
692
693 self.sync_transition_targets_to_current();
694
695 if let Some(b) = brightness {
696 self.target_brightness = b;
697 }
698 if let Some(b) = render_brightness {
699 self.target_render_brightness = b;
700 }
701 if let Some(x) = color_x {
702 self.target_color_x = x;
703 }
704 if let Some(y) = color_y {
705 self.target_color_y = y;
706 }
707 if let Some(t) = color_temp {
708 self.target_color_temp_mireds = t;
709 }
710 if let Some(h) = hue {
711 self.target_hue = h;
712 }
713 if let Some(s) = saturation {
714 self.target_saturation = s;
715 }
716
717 if color_x.is_some() || color_y.is_some() {
718 self.color_mode = ColorMode::XY;
719 } else if color_temp.is_some() {
720 self.color_mode = ColorMode::ColorTemperature;
721 } else if hue.is_some() || saturation.is_some() {
722 self.color_mode = ColorMode::HueSaturation;
723 }
724
725 let duration_ms = u32::from(transition_time_tenths) * 100;
726 self.transition_duration_ms = duration_ms;
727 self.transition_elapsed_ms = 0;
728 self.transition_step_interval_ms = Self::step_interval_for_duration(duration_ms);
729 self.transition_fields = fields;
730 self.transition_final_on = final_on;
731 self.transition_active = true;
732
733 self.render_output();
734 }
735
736 pub fn transition_step_interval_ms(&self) -> u16 {
737 self.transition_step_interval_ms
738 }
739
740 pub fn transition_active(&self) -> bool {
741 self.transition_active
742 }
743
744 pub fn identify_active(&self) -> bool {
745 self.identify_blink_count > 0
746 }
747
748 #[must_use]
751 pub fn step_identify(&mut self) -> bool {
752 if self.identify_blink_count == 0 {
753 return false;
754 }
755 self.identify_blink_count -= 1;
756 self.identify_blink_phase = !self.identify_blink_phase;
757 if self.identify_blink_count == 0 {
758 self.output.set_on(self.on);
759 false
760 } else {
761 self.output.set_on(self.identify_blink_phase);
762 true
763 }
764 }
765
766 #[must_use]
767 pub fn step_transition(&mut self) -> bool {
768 if !self.transition_active {
769 return false;
770 }
771
772 if self.transition_duration_ms == 0 {
773 self.cancel_transition();
774 return false;
775 }
776
777 let step_ms = u32::from(self.transition_step_interval_ms);
778 self.transition_elapsed_ms =
779 (self.transition_elapsed_ms + step_ms).min(self.transition_duration_ms);
780
781 if self.transition_elapsed_ms >= self.transition_duration_ms {
782 self.apply_transition_targets();
783 let final_on = self.transition_final_on.take();
784 self.transition_active = false;
785 self.transition_duration_ms = 0;
786 self.transition_elapsed_ms = 0;
787 self.transition_fields = TransitionFields::default();
788 self.sync_transition_targets_to_current();
789 self.render_output();
790 if let Some(final_on) = final_on {
791 self.on = final_on;
792 self.output.set_on(final_on);
793 }
794 } else {
795 let progress_micro = (self.transition_elapsed_ms as u64 * 1_000_000
797 / self.transition_duration_ms as u64) as u32;
798 self.apply_transition_progress(progress_micro);
799 self.render_output();
800 }
801
802 #[cfg(debug_assertions)]
803 self.validate_color_state();
804
805 self.transition_active
806 }
807
808 fn step_interval_for_duration(duration_ms: u32) -> u16 {
809 if duration_ms <= 300 {
810 20
811 } else {
812 33
813 }
814 }
815
816 fn fade_on(&mut self, transition_time_tenths: u16) {
817 if !self.on {
818 self.on = true;
819 self.output.set_on(true);
820 self.render_brightness = 0;
821 }
822
823 let target = self.brightness.max(1);
824 self.start_transition_with_render(
825 None,
826 None,
827 None,
828 None,
829 None,
830 None,
831 Some(target),
832 None,
833 transition_time_tenths,
834 );
835 }
836
837 fn fade_off(&mut self, transition_time_tenths: u16) {
838 if !self.on {
839 return;
840 }
841
842 self.start_transition_with_render(
843 None,
844 None,
845 None,
846 None,
847 None,
848 None,
849 Some(0),
850 Some(false),
851 transition_time_tenths,
852 );
853 }
854
855 fn cancel_transition(&mut self) {
856 self.transition_active = false;
857 self.transition_duration_ms = 0;
858 self.transition_elapsed_ms = 0;
859 self.transition_fields = TransitionFields::default();
860 self.transition_final_on = None;
861 self.sync_transition_targets_to_current();
862 }
863
864 fn sync_transition_targets_to_current(&mut self) {
865 self.target_brightness = self.brightness;
866 self.target_render_brightness = self.render_brightness;
867 self.target_color_x = self.color_x;
868 self.target_color_y = self.color_y;
869 self.target_color_temp_mireds = self.color_temp_mireds;
870 self.target_hue = self.hue;
871 self.target_saturation = self.saturation;
872 }
873
874 fn apply_transition_targets(&mut self) {
875 let fields = self.transition_fields;
876 if fields.brightness {
877 self.brightness = self.target_brightness;
878 }
879 if fields.render_brightness {
880 self.render_brightness = self.target_render_brightness;
881 }
882 if fields.color_x {
883 self.color_x = self.target_color_x;
884 }
885 if fields.color_y {
886 self.color_y = self.target_color_y;
887 }
888 if fields.color_temp_mireds {
889 self.color_temp_mireds = self.target_color_temp_mireds;
890 }
891 if fields.hue {
892 self.hue = self.target_hue;
893 }
894 if fields.saturation {
895 self.saturation = self.target_saturation;
896 }
897 }
898
899 fn apply_transition_progress(&mut self, progress_micro: u32) {
900 let fields = self.transition_fields;
901 if fields.brightness {
902 self.brightness = Self::lerp_u8(
903 self.start_brightness,
904 self.target_brightness,
905 progress_micro,
906 );
907 }
908 if fields.render_brightness {
909 self.render_brightness = Self::lerp_u8(
910 self.start_render_brightness,
911 self.target_render_brightness,
912 progress_micro,
913 );
914 }
915 if fields.color_x {
916 self.color_x = Self::lerp_u16(self.start_color_x, self.target_color_x, progress_micro);
917 }
918 if fields.color_y {
919 self.color_y = Self::lerp_u16(self.start_color_y, self.target_color_y, progress_micro);
920 }
921 if fields.color_temp_mireds {
922 self.color_temp_mireds = Self::lerp_u16(
923 self.start_color_temp_mireds,
924 self.target_color_temp_mireds,
925 progress_micro,
926 );
927 }
928 if fields.hue {
929 self.hue = Self::lerp_hue(self.start_hue, self.target_hue, progress_micro);
930 }
931 if fields.saturation {
932 self.saturation = Self::lerp_u8(
933 self.start_saturation,
934 self.target_saturation,
935 progress_micro,
936 );
937 }
938 }
939
940 fn lerp_u8(start: u8, target: u8, progress_micro: u32) -> u8 {
943 let s = start as u32;
944 let t = target as u32;
945 if t >= s {
946 (s + ((t - s) * progress_micro + 500_000) / 1_000_000) as u8
947 } else {
948 (s - ((s - t) * progress_micro + 500_000) / 1_000_000) as u8
949 }
950 }
951
952 fn lerp_u16(start: u16, target: u16, progress_micro: u32) -> u16 {
955 let s = start as u64;
956 let t = target as u64;
957 let p = progress_micro as u64;
958 if t >= s {
959 (s + ((t - s) * p + 500_000) / 1_000_000) as u16
960 } else {
961 (s - ((s - t) * p + 500_000) / 1_000_000) as u16
962 }
963 }
964
965 fn lerp_hue(start: u8, target: u8, progress_micro: u32) -> u8 {
971 const WHEEL: i64 = 255;
972 let s = i64::from(start);
973 let t = i64::from(target);
974
975 let mut delta = (t - s).rem_euclid(WHEEL);
977 if delta > WHEEL / 2 {
978 delta -= WHEEL;
979 }
980
981 let p = i64::from(progress_micro);
982 let offset = if delta >= 0 {
983 (delta * p + 500_000) / 1_000_000
984 } else {
985 -((-delta * p + 500_000) / 1_000_000)
986 };
987
988 (s + offset).rem_euclid(WHEEL) as u8
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 #[test]
997 fn lerp_hue_endpoints_are_exact() {
998 assert_eq!(LightState::lerp_hue(240, 10, 0), 240);
999 assert_eq!(LightState::lerp_hue(240, 10, 1_000_000), 10);
1000 }
1001
1002 #[test]
1003 fn lerp_hue_takes_the_shortest_arc_across_the_wrap() {
1004 let mid = LightState::lerp_hue(240, 10, 500_000);
1009 assert!(
1010 !(15..=240).contains(&mid),
1011 "midpoint {mid} left the wrap region — long-way regression"
1012 );
1013 assert!(
1014 !(60..=195).contains(&mid),
1015 "midpoint {mid} is in the far half of the wheel"
1016 );
1017
1018 let mid_rev = LightState::lerp_hue(10, 240, 500_000);
1020 assert!(
1021 !(15..=240).contains(&mid_rev),
1022 "reverse midpoint {mid_rev} left the wrap region"
1023 );
1024 }
1025}