Skip to main content

huesmith_core/light/
state.rs

1use crate::color;
2use crate::light::command::LightCommand;
3use crate::light::LightOutput;
4
5/// Color mode of the light, matching ZCL color modes.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ColorMode {
8    HueSaturation,
9    XY,
10    ColorTemperature,
11}
12
13/// Complete raw light state handed to [`LightOutput::state_update`] after every
14/// render pass.
15///
16/// Unlike [`SceneState`] (a *partial* snapshot for Zigbee scene storage, where
17/// absent fields mean "don't touch"), every field here is always present — no
18/// `Option`s to unwrap. All values are raw ZCL domains.
19///
20/// `#[non_exhaustive]`: backends receive this by reference and read its fields,
21/// so new state (e.g. a future color-loop phase) can be added without a breaking
22/// release. It does mean the struct can't be constructed with a literal outside
23/// this crate — not a use case, since only the state machine produces it.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct LightSnapshot {
27    pub on: bool,
28    /// Logical level (0-254): the target the light is at or heading to. Stays
29    /// put during an on/off fade.
30    pub brightness: u8,
31    /// The level actually being driven right now (0-254): ramps frame by frame
32    /// during fades and transitions — key effects off this one to follow what
33    /// the LED is visibly doing.
34    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/// Partial light-state snapshot stored by the Zigbee Scenes cluster.
45#[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
81/// Complete light state machine.
82pub 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    /// Coalesces split XY color attribute updates that arrive in separate
95    /// SET_ATTR callbacks from the Hue Bridge.
96    pending_color_x: Option<u16>,
97    pending_color_y: Option<u16>,
98
99    /// Identify blinking state. ZCL spec: blink at 0.5 Hz for `duration` seconds.
100    /// `identify_blink_count` counts remaining half-cycles; 0 = not identifying.
101    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    /// Create a state machine that starts physically on.
168    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    /// The complete raw state, as delivered to [`LightOutput::state_update`].
179    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    /// Capture all light attributes that this firmware supports in Zigbee scenes.
195    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                // start_transition_with_render auto-detects the mode from the
260                // animated fields and has already rendered one frame with it.
261                // Re-render only when the explicit mode disagrees (a
262                // HueSaturation scene's wire format also carries X/Y, fooling
263                // the presence-based auto-detect into XY); an unconditional
264                // re-render would send a second identical full-strip frame on
265                // every recall.
266                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                    // ZCL spec: blink at 0.5 Hz for duration seconds = 2 half-cycles/s.
459                    // u32 math: `duration` can be up to 0xFFFF, so `* 2` overflows u16.
460                    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                // Reuse the identify blink timer for all visual effects.
473                // All effects run at 0.5 Hz (500 ms half-cycle) via the existing identify alarm.
474                match effect_id {
475                    0xFF => {
476                        // StopEffect: cancel immediately and restore light state
477                        if self.identify_blink_count > 0 {
478                            self.identify_blink_count = 0;
479                            self.output.set_on(self.on);
480                        }
481                    }
482                    0xFE => {
483                        // FinishEffect: let current identify run to completion
484                    }
485                    0x00 => {
486                        // Blink: ZCL defines it as "light is turned on/off
487                        // once" — exactly one cycle (2 half-cycles).
488                        self.identify_blink_count = 2;
489                        self.identify_blink_phase = false;
490                        self.output.set_on(false);
491                    }
492                    _ => {
493                        // Remaining "which light is this?" effects — Breathe
494                        // (0x01), Okay (0x02, ZCL: "flash twice"), Channel
495                        // Change (0x0B), and unknowns — blink twice
496                        // (4 half-cycles at 0.5 Hz ≈ 2 s). The Hue app sends
497                        // 0x01 on tap to identify a light; a long animation
498                        // just looks like it never stops, and a re-tap
499                        // restarts it anyway.
500                        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        // Raw-state observation hook for custom backends (default no-op).
545        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    // Each argument is an independent, optional ZCL transition target; bundling
570    // them into a struct would not improve clarity and would churn every caller.
571    #[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        // Single normalization chokepoint for the ZCL 0-254 domains: every
609        // command and scene-transition path funnels through here, so a raw
610        // wire value of 255 (reserved in ZCL) can never enter the state fields
611        // and later wrap 8-bit scaling math or the enhanced-hue encoding.
612        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    /// Advance one identify blink half-cycle (~500 ms). Returns true if more
749    /// blinks remain and the caller should schedule another 500 ms alarm.
750    #[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            // Integer progress in units of 1/1_000_000 to avoid f32 precision loss.
796            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    /// Round-to-nearest integer linear interpolation. `progress_micro` is in
941    /// the range 0..=1_000_000 (0 = start, 1_000_000 = target).
942    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    /// Round-to-nearest integer linear interpolation for u16.
953    /// Uses u64 internally to avoid overflow (max diff 65535 × 1_000_000).
954    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    /// Wrap-aware hue interpolation on the circular ZCL hue wheel.
966    ///
967    /// ZCL `CurrentHue` runs 0..=254 and wraps (254 is adjacent to 0, modulus
968    /// 255). A plain linear lerp from 240 to 10 would sweep the long way
969    /// through green; real Hue bulbs take the shortest arc, so we do too.
970    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        // Signed shortest-arc delta in (-127..=127].
976        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        // 240 -> 10 is 25 steps forward across the 254/0 seam, not 230 steps
1005        // backward through the middle of the wheel. A regression to a plain
1006        // linear lerp would land near 125 (the long way); the wrap-aware path
1007        // stays up in the 240s/low single digits.
1008        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        // Same seam from the other side: 10 -> 240 goes 25 steps backward.
1019        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}