use crate::color;
use crate::light::command::LightCommand;
use crate::light::LightOutput;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
HueSaturation,
XY,
ColorTemperature,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct LightSnapshot {
pub on: bool,
pub brightness: u8,
pub rendered_brightness: u8,
pub color_mode: ColorMode,
pub hue: u8,
pub saturation: u8,
pub enhanced_hue: u16,
pub color_x: u16,
pub color_y: u16,
pub color_temp_mireds: u16,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SceneState {
pub on: Option<bool>,
pub brightness: Option<u8>,
pub color_mode: Option<ColorMode>,
pub hue: Option<u8>,
pub saturation: Option<u8>,
pub enhanced_hue: Option<u16>,
pub color_x: Option<u16>,
pub color_y: Option<u16>,
pub color_temp_mireds: Option<u16>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
struct TransitionFields {
brightness: bool,
render_brightness: bool,
color_x: bool,
color_y: bool,
color_temp_mireds: bool,
hue: bool,
saturation: bool,
}
impl TransitionFields {
fn any(self) -> bool {
self.brightness
|| self.render_brightness
|| self.color_x
|| self.color_y
|| self.color_temp_mireds
|| self.hue
|| self.saturation
}
}
pub struct LightState {
pub on: bool,
pub brightness: u8,
pub color_mode: ColorMode,
pub hue: u8,
pub saturation: u8,
pub color_x: u16,
pub color_y: u16,
pub color_temp_mireds: u16,
render_brightness: u8,
output: Box<dyn LightOutput>,
pending_color_x: Option<u16>,
pending_color_y: Option<u16>,
identify_blink_count: u32,
identify_blink_phase: bool,
transition_active: bool,
transition_duration_ms: u32,
transition_elapsed_ms: u32,
transition_step_interval_ms: u16,
transition_fields: TransitionFields,
transition_final_on: Option<bool>,
start_brightness: u8,
start_render_brightness: u8,
start_color_x: u16,
start_color_y: u16,
start_color_temp_mireds: u16,
start_hue: u8,
start_saturation: u8,
target_brightness: u8,
target_render_brightness: u8,
target_color_x: u16,
target_color_y: u16,
target_color_temp_mireds: u16,
target_hue: u8,
target_saturation: u8,
}
impl LightState {
pub fn new(output: Box<dyn LightOutput>) -> Self {
Self {
on: false,
brightness: 254,
color_mode: ColorMode::ColorTemperature,
hue: 0,
saturation: 0,
color_x: 20495,
color_y: 21561,
color_temp_mireds: 370,
render_brightness: 0,
output,
pending_color_x: None,
pending_color_y: None,
identify_blink_count: 0,
identify_blink_phase: false,
transition_active: false,
transition_duration_ms: 0,
transition_elapsed_ms: 0,
transition_step_interval_ms: 50,
transition_fields: TransitionFields::default(),
transition_final_on: None,
start_brightness: 254,
start_render_brightness: 0,
start_color_x: 20495,
start_color_y: 21561,
start_color_temp_mireds: 370,
start_hue: 0,
start_saturation: 0,
target_brightness: 254,
target_render_brightness: 0,
target_color_x: 20495,
target_color_y: 21561,
target_color_temp_mireds: 370,
target_hue: 0,
target_saturation: 0,
}
}
pub fn new_powered_on(output: Box<dyn LightOutput>) -> Self {
let mut state = Self::new(output);
state.on = true;
state.render_brightness = state.brightness;
state.output.set_on(true);
state.render_output();
state.sync_transition_targets_to_current();
state
}
pub fn light_snapshot(&self) -> LightSnapshot {
LightSnapshot {
on: self.on,
brightness: self.brightness,
rendered_brightness: if self.on { self.render_brightness } else { 0 },
color_mode: self.color_mode,
hue: self.hue,
saturation: self.saturation,
enhanced_hue: color::hue_to_enhanced_hue(self.hue),
color_x: self.color_x,
color_y: self.color_y,
color_temp_mireds: self.color_temp_mireds,
}
}
pub fn scene_snapshot(&self) -> SceneState {
SceneState {
on: Some(self.on),
brightness: Some(self.brightness),
color_mode: Some(self.color_mode),
hue: Some(self.hue),
saturation: Some(self.saturation),
enhanced_hue: Some(color::hue_to_enhanced_hue(self.hue)),
color_x: Some(self.color_x),
color_y: Some(self.color_y),
color_temp_mireds: Some(self.color_temp_mireds),
}
}
pub fn apply_scene_state(&mut self, scene: &SceneState) {
self.apply_scene_state_with_transition(scene, 0);
}
pub fn apply_scene_state_with_transition(
&mut self,
scene: &SceneState,
transition_time_tenths: u16,
) {
self.cancel_transition();
if transition_time_tenths > 0 {
self.pending_color_x = None;
self.pending_color_y = None;
if scene.on == Some(true) && !self.on {
self.on = true;
self.output.set_on(true);
self.render_brightness = 0;
}
let final_on = if scene.on == Some(false) {
Some(false)
} else {
None
};
let render_target = if scene.on == Some(false) {
Some(0)
} else if scene.on == Some(true) || scene.brightness.is_some() {
Some(scene.brightness.unwrap_or(self.brightness))
} else {
None
};
self.start_transition_with_render(
scene.brightness,
scene.color_x,
scene.color_y,
scene.color_temp_mireds,
scene
.enhanced_hue
.map(color::enhanced_hue_to_hue)
.or(scene.hue),
scene.saturation,
render_target,
final_on,
transition_time_tenths,
);
if let Some(mode) = scene.color_mode {
if mode != self.color_mode {
self.color_mode = mode;
self.render_output();
}
}
#[cfg(debug_assertions)]
self.validate_color_state();
return;
}
if let Some(level) = scene.brightness {
self.brightness = level.min(254);
}
if let Some(x) = scene.color_x {
self.color_x = x;
self.color_mode = ColorMode::XY;
self.pending_color_x = None;
self.pending_color_y = None;
}
if let Some(y) = scene.color_y {
self.color_y = y;
self.color_mode = ColorMode::XY;
self.pending_color_x = None;
self.pending_color_y = None;
}
if let Some(temp) = scene.color_temp_mireds {
self.color_temp_mireds = temp;
self.color_mode = ColorMode::ColorTemperature;
self.pending_color_x = None;
self.pending_color_y = None;
}
if let Some(enhanced_hue) = scene.enhanced_hue {
self.hue = color::enhanced_hue_to_hue(enhanced_hue);
self.color_mode = ColorMode::HueSaturation;
self.pending_color_x = None;
self.pending_color_y = None;
} else if let Some(hue) = scene.hue {
self.hue = hue.min(254);
self.color_mode = ColorMode::HueSaturation;
self.pending_color_x = None;
self.pending_color_y = None;
}
if let Some(saturation) = scene.saturation {
self.saturation = saturation.min(254);
self.color_mode = ColorMode::HueSaturation;
self.pending_color_x = None;
self.pending_color_y = None;
}
if let Some(mode) = scene.color_mode {
self.color_mode = mode;
if mode != ColorMode::XY {
self.pending_color_x = None;
self.pending_color_y = None;
}
}
if let Some(on) = scene.on {
self.on = on;
if on {
self.render_brightness = self.brightness;
self.output.set_on(true);
} else {
self.render_brightness = 0;
}
}
if scene.on.is_none() && self.on && scene.brightness.is_some() {
self.render_brightness = self.brightness;
}
self.render_output();
if !self.on {
self.output.set_on(false);
}
self.sync_transition_targets_to_current();
#[cfg(debug_assertions)]
self.validate_color_state();
}
pub fn apply_command(&mut self, cmd: &LightCommand) {
match cmd {
LightCommand::On => {
self.fade_on(crate::light::command::DEFAULT_ON_OFF_TRANSITION_TENTHS);
}
LightCommand::Off => {
self.fade_off(crate::light::command::DEFAULT_ON_OFF_TRANSITION_TENTHS);
}
LightCommand::Toggle => {
if self.on {
self.fade_off(crate::light::command::DEFAULT_ON_OFF_TRANSITION_TENTHS);
} else {
self.fade_on(crate::light::command::DEFAULT_ON_OFF_TRANSITION_TENTHS);
}
}
LightCommand::SetLevel {
level,
transition_time,
} => {
if *level > 0 && !self.on {
self.on = true;
self.output.set_on(true);
self.render_brightness = 0;
}
self.start_transition(Some(*level), None, None, None, None, None, *transition_time);
}
LightCommand::MoveToColor {
x,
y,
transition_time,
} => {
if let Some(x) = x {
self.pending_color_x = Some(*x);
}
if let Some(y) = y {
self.pending_color_y = Some(*y);
}
let have_both = self.pending_color_x.is_some() && self.pending_color_y.is_some();
let command_had_both = x.is_some() && y.is_some();
if have_both || command_had_both {
self.start_transition(
None,
self.pending_color_x,
self.pending_color_y,
None,
None,
None,
*transition_time,
);
self.pending_color_x = None;
self.pending_color_y = None;
}
}
LightCommand::MoveToColorTemp {
mireds,
transition_time,
} => {
self.pending_color_x = None;
self.pending_color_y = None;
self.start_transition(
None,
None,
None,
Some(*mireds),
None,
None,
*transition_time,
);
}
LightCommand::MoveToHueAndSaturation {
hue,
saturation,
transition_time,
} => {
self.pending_color_x = None;
self.pending_color_y = None;
self.start_transition(None, None, None, None, *hue, *saturation, *transition_time);
}
LightCommand::EnhancedMoveToHueAndSaturation {
enhanced_hue,
saturation,
transition_time,
} => {
self.pending_color_x = None;
self.pending_color_y = None;
let hue = color::enhanced_hue_to_hue(*enhanced_hue);
self.start_transition(
None,
None,
None,
None,
Some(hue),
*saturation,
*transition_time,
);
}
LightCommand::Identify { duration } => {
self.pending_color_x = None;
self.pending_color_y = None;
if *duration == 0 {
if self.identify_blink_count > 0 {
self.identify_blink_count = 0;
self.output.set_on(self.on);
}
} else {
let half_cycles = u32::from(*duration) * 2;
log::info!(
"Identify: {} seconds ({} half-cycles)",
duration,
half_cycles
);
self.identify_blink_count = half_cycles;
self.identify_blink_phase = false;
self.output.set_on(false);
}
}
LightCommand::TriggerEffect { effect_id, .. } => {
match effect_id {
0xFF => {
if self.identify_blink_count > 0 {
self.identify_blink_count = 0;
self.output.set_on(self.on);
}
}
0xFE => {
}
0x00 => {
self.identify_blink_count = 2;
self.identify_blink_phase = false;
self.output.set_on(false);
}
_ => {
self.identify_blink_count = 4;
self.identify_blink_phase = false;
self.output.set_on(false);
}
}
}
LightCommand::StopMoveStep => {
self.cancel_transition();
self.pending_color_x = None;
self.pending_color_y = None;
}
}
#[cfg(debug_assertions)]
self.validate_color_state();
}
fn apply_current_color_at(&mut self, brightness: u8) {
if !self.on {
return;
}
let (r, g, b) = match self.color_mode {
ColorMode::XY => color::xy_to_rgb(self.color_x, self.color_y, brightness),
ColorMode::ColorTemperature => {
color::scale_rgb(color::mireds_to_rgb(self.color_temp_mireds), brightness)
}
ColorMode::HueSaturation => color::hsv_to_rgb(self.hue, self.saturation, brightness),
};
self.output.set_rgb(r, g, b);
}
fn render_output(&mut self) {
let brightness = if self.on { self.render_brightness } else { 0 };
self.output.set_brightness(brightness);
if brightness == 0 {
self.output.set_rgb(0, 0, 0);
} else if self.color_mode == ColorMode::ColorTemperature {
self.output.set_color_temp(self.color_temp_mireds);
} else {
self.apply_current_color_at(brightness);
}
let snapshot = self.light_snapshot();
self.output.state_update(&snapshot);
}
#[cfg(debug_assertions)]
fn validate_color_state(&self) {
match self.color_mode {
ColorMode::XY => {
if self.color_x == 0 && self.color_y == 0 {
log::warn!("validate: XY mode but both color_x/y are zero");
}
}
ColorMode::ColorTemperature => {
if self.color_temp_mireds < 153 || self.color_temp_mireds > 500 {
log::warn!(
"validate: ColorTemperature mireds out of range: {}",
self.color_temp_mireds
);
}
}
ColorMode::HueSaturation => {}
}
}
#[allow(clippy::too_many_arguments)]
pub fn start_transition(
&mut self,
brightness: Option<u8>,
color_x: Option<u16>,
color_y: Option<u16>,
color_temp: Option<u16>,
hue: Option<u8>,
saturation: Option<u8>,
transition_time_tenths: u16,
) {
self.start_transition_with_render(
brightness,
color_x,
color_y,
color_temp,
hue,
saturation,
None,
None,
transition_time_tenths,
);
}
#[allow(clippy::too_many_arguments)]
fn start_transition_with_render(
&mut self,
brightness: Option<u8>,
color_x: Option<u16>,
color_y: Option<u16>,
color_temp: Option<u16>,
hue: Option<u8>,
saturation: Option<u8>,
render_brightness: Option<u8>,
final_on: Option<bool>,
transition_time_tenths: u16,
) {
let brightness = brightness.map(|b| b.min(254));
let hue = hue.map(|h| h.min(254));
let saturation = saturation.map(|s| s.min(254));
let render_brightness = render_brightness.map(|b| b.min(254));
let render_brightness = render_brightness.or_else(|| {
if brightness.is_some() && self.on {
brightness
} else {
None
}
});
let fields = TransitionFields {
brightness: brightness.is_some(),
render_brightness: render_brightness.is_some(),
color_x: color_x.is_some(),
color_y: color_y.is_some(),
color_temp_mireds: color_temp.is_some(),
hue: hue.is_some(),
saturation: saturation.is_some(),
};
if !fields.any() {
if let Some(final_on) = final_on {
self.on = final_on;
self.output.set_on(final_on);
}
return;
}
self.cancel_transition();
if transition_time_tenths == 0 {
if let Some(b) = brightness {
self.brightness = b;
}
if let Some(b) = render_brightness {
self.render_brightness = b;
}
if let Some(x) = color_x {
self.color_x = x;
self.color_mode = ColorMode::XY;
}
if let Some(y) = color_y {
self.color_y = y;
self.color_mode = ColorMode::XY;
}
if let Some(t) = color_temp {
self.color_temp_mireds = t;
self.color_mode = ColorMode::ColorTemperature;
}
if let Some(h) = hue {
self.hue = h;
self.color_mode = ColorMode::HueSaturation;
}
if let Some(s) = saturation {
self.saturation = s;
self.color_mode = ColorMode::HueSaturation;
}
if final_on == Some(true) && !self.on {
self.on = true;
self.output.set_on(true);
}
self.render_output();
if final_on == Some(false) {
self.on = false;
self.output.set_on(false);
}
self.sync_transition_targets_to_current();
return;
}
self.start_brightness = self.brightness;
self.start_render_brightness = self.render_brightness;
self.start_color_x = self.color_x;
self.start_color_y = self.color_y;
self.start_color_temp_mireds = self.color_temp_mireds;
self.start_hue = self.hue;
self.start_saturation = self.saturation;
self.sync_transition_targets_to_current();
if let Some(b) = brightness {
self.target_brightness = b;
}
if let Some(b) = render_brightness {
self.target_render_brightness = b;
}
if let Some(x) = color_x {
self.target_color_x = x;
}
if let Some(y) = color_y {
self.target_color_y = y;
}
if let Some(t) = color_temp {
self.target_color_temp_mireds = t;
}
if let Some(h) = hue {
self.target_hue = h;
}
if let Some(s) = saturation {
self.target_saturation = s;
}
if color_x.is_some() || color_y.is_some() {
self.color_mode = ColorMode::XY;
} else if color_temp.is_some() {
self.color_mode = ColorMode::ColorTemperature;
} else if hue.is_some() || saturation.is_some() {
self.color_mode = ColorMode::HueSaturation;
}
let duration_ms = u32::from(transition_time_tenths) * 100;
self.transition_duration_ms = duration_ms;
self.transition_elapsed_ms = 0;
self.transition_step_interval_ms = Self::step_interval_for_duration(duration_ms);
self.transition_fields = fields;
self.transition_final_on = final_on;
self.transition_active = true;
self.render_output();
}
pub fn transition_step_interval_ms(&self) -> u16 {
self.transition_step_interval_ms
}
pub fn transition_active(&self) -> bool {
self.transition_active
}
pub fn identify_active(&self) -> bool {
self.identify_blink_count > 0
}
#[must_use]
pub fn step_identify(&mut self) -> bool {
if self.identify_blink_count == 0 {
return false;
}
self.identify_blink_count -= 1;
self.identify_blink_phase = !self.identify_blink_phase;
if self.identify_blink_count == 0 {
self.output.set_on(self.on);
false
} else {
self.output.set_on(self.identify_blink_phase);
true
}
}
#[must_use]
pub fn step_transition(&mut self) -> bool {
if !self.transition_active {
return false;
}
if self.transition_duration_ms == 0 {
self.cancel_transition();
return false;
}
let step_ms = u32::from(self.transition_step_interval_ms);
self.transition_elapsed_ms =
(self.transition_elapsed_ms + step_ms).min(self.transition_duration_ms);
if self.transition_elapsed_ms >= self.transition_duration_ms {
self.apply_transition_targets();
let final_on = self.transition_final_on.take();
self.transition_active = false;
self.transition_duration_ms = 0;
self.transition_elapsed_ms = 0;
self.transition_fields = TransitionFields::default();
self.sync_transition_targets_to_current();
self.render_output();
if let Some(final_on) = final_on {
self.on = final_on;
self.output.set_on(final_on);
}
} else {
let progress_micro = (self.transition_elapsed_ms as u64 * 1_000_000
/ self.transition_duration_ms as u64) as u32;
self.apply_transition_progress(progress_micro);
self.render_output();
}
#[cfg(debug_assertions)]
self.validate_color_state();
self.transition_active
}
fn step_interval_for_duration(duration_ms: u32) -> u16 {
if duration_ms <= 300 {
20
} else {
33
}
}
fn fade_on(&mut self, transition_time_tenths: u16) {
if !self.on {
self.on = true;
self.output.set_on(true);
self.render_brightness = 0;
}
let target = self.brightness.max(1);
self.start_transition_with_render(
None,
None,
None,
None,
None,
None,
Some(target),
None,
transition_time_tenths,
);
}
fn fade_off(&mut self, transition_time_tenths: u16) {
if !self.on {
return;
}
self.start_transition_with_render(
None,
None,
None,
None,
None,
None,
Some(0),
Some(false),
transition_time_tenths,
);
}
fn cancel_transition(&mut self) {
self.transition_active = false;
self.transition_duration_ms = 0;
self.transition_elapsed_ms = 0;
self.transition_fields = TransitionFields::default();
self.transition_final_on = None;
self.sync_transition_targets_to_current();
}
fn sync_transition_targets_to_current(&mut self) {
self.target_brightness = self.brightness;
self.target_render_brightness = self.render_brightness;
self.target_color_x = self.color_x;
self.target_color_y = self.color_y;
self.target_color_temp_mireds = self.color_temp_mireds;
self.target_hue = self.hue;
self.target_saturation = self.saturation;
}
fn apply_transition_targets(&mut self) {
let fields = self.transition_fields;
if fields.brightness {
self.brightness = self.target_brightness;
}
if fields.render_brightness {
self.render_brightness = self.target_render_brightness;
}
if fields.color_x {
self.color_x = self.target_color_x;
}
if fields.color_y {
self.color_y = self.target_color_y;
}
if fields.color_temp_mireds {
self.color_temp_mireds = self.target_color_temp_mireds;
}
if fields.hue {
self.hue = self.target_hue;
}
if fields.saturation {
self.saturation = self.target_saturation;
}
}
fn apply_transition_progress(&mut self, progress_micro: u32) {
let fields = self.transition_fields;
if fields.brightness {
self.brightness = Self::lerp_u8(
self.start_brightness,
self.target_brightness,
progress_micro,
);
}
if fields.render_brightness {
self.render_brightness = Self::lerp_u8(
self.start_render_brightness,
self.target_render_brightness,
progress_micro,
);
}
if fields.color_x {
self.color_x = Self::lerp_u16(self.start_color_x, self.target_color_x, progress_micro);
}
if fields.color_y {
self.color_y = Self::lerp_u16(self.start_color_y, self.target_color_y, progress_micro);
}
if fields.color_temp_mireds {
self.color_temp_mireds = Self::lerp_u16(
self.start_color_temp_mireds,
self.target_color_temp_mireds,
progress_micro,
);
}
if fields.hue {
self.hue = Self::lerp_hue(self.start_hue, self.target_hue, progress_micro);
}
if fields.saturation {
self.saturation = Self::lerp_u8(
self.start_saturation,
self.target_saturation,
progress_micro,
);
}
}
fn lerp_u8(start: u8, target: u8, progress_micro: u32) -> u8 {
let s = start as u32;
let t = target as u32;
if t >= s {
(s + ((t - s) * progress_micro + 500_000) / 1_000_000) as u8
} else {
(s - ((s - t) * progress_micro + 500_000) / 1_000_000) as u8
}
}
fn lerp_u16(start: u16, target: u16, progress_micro: u32) -> u16 {
let s = start as u64;
let t = target as u64;
let p = progress_micro as u64;
if t >= s {
(s + ((t - s) * p + 500_000) / 1_000_000) as u16
} else {
(s - ((s - t) * p + 500_000) / 1_000_000) as u16
}
}
fn lerp_hue(start: u8, target: u8, progress_micro: u32) -> u8 {
const WHEEL: i64 = 255;
let s = i64::from(start);
let t = i64::from(target);
let mut delta = (t - s).rem_euclid(WHEEL);
if delta > WHEEL / 2 {
delta -= WHEEL;
}
let p = i64::from(progress_micro);
let offset = if delta >= 0 {
(delta * p + 500_000) / 1_000_000
} else {
-((-delta * p + 500_000) / 1_000_000)
};
(s + offset).rem_euclid(WHEEL) as u8
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lerp_hue_endpoints_are_exact() {
assert_eq!(LightState::lerp_hue(240, 10, 0), 240);
assert_eq!(LightState::lerp_hue(240, 10, 1_000_000), 10);
}
#[test]
fn lerp_hue_takes_the_shortest_arc_across_the_wrap() {
let mid = LightState::lerp_hue(240, 10, 500_000);
assert!(
!(15..=240).contains(&mid),
"midpoint {mid} left the wrap region — long-way regression"
);
assert!(
!(60..=195).contains(&mid),
"midpoint {mid} is in the far half of the wheel"
);
let mid_rev = LightState::lerp_hue(10, 240, 500_000);
assert!(
!(15..=240).contains(&mid_rev),
"reverse midpoint {mid_rev} left the wrap region"
);
}
}