use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LightValueUnit {
Percent,
Lumens,
Kelvin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
pub struct LightValueRange {
min: u16,
max: u16,
step: u16,
unit: LightValueUnit,
}
impl LightValueRange {
pub const fn new(
min: u16,
max: u16,
step: u16,
unit: LightValueUnit,
) -> Result<Self, LightValueRangeError> {
if min > max {
return Err(LightValueRangeError::Reversed { min, max });
}
if step == 0 {
return Err(LightValueRangeError::ZeroStep);
}
if !(max - min).is_multiple_of(step) {
return Err(LightValueRangeError::Unaligned { min, max, step });
}
if matches!(unit, LightValueUnit::Percent) && max > 100 {
return Err(LightValueRangeError::PercentOutOfBounds { min, max });
}
Ok(Self {
min,
max,
step,
unit,
})
}
#[must_use]
pub const fn min(self) -> u16 {
self.min
}
#[must_use]
pub const fn max(self) -> u16 {
self.max
}
#[must_use]
pub const fn step(self) -> u16 {
self.step
}
#[must_use]
pub const fn unit(self) -> LightValueUnit {
self.unit
}
#[must_use]
pub fn contains(self, value: u16) -> bool {
value >= self.min
&& value <= self.max
&& self.step != 0
&& (value - self.min).is_multiple_of(self.step)
}
#[must_use]
pub fn quantize(self, value: u16) -> u16 {
let clamped = value.clamp(self.min, self.max);
let offset = clamped - self.min;
let lower = offset / self.step;
let remainder = offset % self.step;
let index = if remainder.saturating_mul(2) >= self.step {
lower.saturating_add(1)
} else {
lower
};
self.min
.saturating_add(index.saturating_mul(self.step))
.min(self.max)
}
#[must_use]
pub fn native_for_percent(self, percent: u8) -> Option<u16> {
if percent > 100 {
return None;
}
let span = u32::from(self.max) - u32::from(self.min);
let raw = u32::from(self.min) + (span * u32::from(percent) + 50) / 100;
u16::try_from(raw).ok().map(|value| self.quantize(value))
}
#[must_use]
pub fn percent_for_native(self, value: u16) -> Option<u8> {
if !self.contains(value) {
return None;
}
let span = u32::from(self.max) - u32::from(self.min);
if span == 0 {
return Some(0);
}
u8::try_from(((u32::from(value) - u32::from(self.min)) * 100 + span / 2) / span).ok()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum LightValueRangeError {
#[error("light range minimum {min} is greater than maximum {max}")]
Reversed {
min: u16,
max: u16,
},
#[error("light range step must be non-zero")]
ZeroStep,
#[error("light range {min}..={max} is not aligned to step {step}")]
Unaligned {
min: u16,
max: u16,
step: u16,
},
#[error("percentage light range {min}..={max} exceeds 0..=100")]
PercentOutOfBounds {
min: u16,
max: u16,
},
}
#[derive(Deserialize)]
struct RawLightValueRange {
min: u16,
max: u16,
step: u16,
unit: LightValueUnit,
}
impl<'de> Deserialize<'de> for LightValueRange {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawLightValueRange::deserialize(deserializer)?;
Self::new(raw.min, raw.max, raw.step, raw.unit).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[allow(
clippy::struct_excessive_bools,
reason = "independent optional light controls are a serialized capability DTO"
)]
pub struct LightCapabilities {
pub power: bool,
pub brightness: Option<LightValueRange>,
pub temperature: Option<LightValueRange>,
#[serde(default)]
pub color: bool,
#[serde(default)]
pub zones: bool,
}